on main help sign in
Give each install its own random style

A fresh install creates a 64-bit seed (SecureRandom) in its own preferences file, rebrand_style, picks one day and one night entry from the style table and, inside Theme's static initializer, turns them into two local accents (first free id >= 101, no pattern) that are selected before the first frame. The auto-night mode stays stock; any failure keeps the stock style. - Existing installs (theme settings present) are never restyled automatically. "Shuffle my style" in Chat Settings works for everyone: new seed, applied at once for the variant that is showing, with Undo on a bulletin when the generated style was showing. Unedited previous generated accents are removed (an accent with its own chat background counts as edited); custom themes are untouched. - "Reset to defaults" in Chat Settings, which in stock only resets text size and bubble radius, also returns the colours to the install's generated style unless a custom, cloud or Monet theme is selected. - The custom tile of Chat Settings' theme strip is rebuilt when Shuffle, Undo or Reset replace the accents it points to. - Generated accents, their edits and copies never leave the device: saveThemeToServer refuses them, share and create-theme explain why. Records carry a colour fingerprint, so an imported accent that happens to reuse a generated id is not mistaken for one. The seed is in neither Android backup nor Forkgram's settings export. - Every upstream hook is one inserted line marked "MultiGram:". multigram/random-style/README.md documents the design, the privacy and backup decisions and the API for other features; random_style_check.py (run by check.sh) guards the hooks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mVy1fu8zBn38wHgJqmLNz

commit601bfb1fd021115766b45e4264524f98bb76eac9 authorClaude <noreply@anthropic.com> dateSun 27 Sep 2026, 03:16 (2 weeks ago) parenta1aa43b
15 files changed+1936
TMessagesProj/src/main/java/org/telegram/messenger/ApplicationLoader.java+1 TMessagesProj/src/main/java/org/telegram/messenger/MessagesController.java+1 TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyle.java+923 TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyleUi.java+161 TMessagesProj/src/main/java/org/telegram/messenger/multigram/StyleTable.java+286 TMessagesProj/src/main/java/org/telegram/ui/ActionBar/Theme.java+3 TMessagesProj/src/main/java/org/telegram/ui/Components/AlertsCreator.java+1 TMessagesProj/src/main/java/org/telegram/ui/DefaultThemesPreviewCell.java+1 TMessagesProj/src/main/java/org/telegram/ui/ThemeActivity.java+8 TMessagesProj/src/main/java/org/telegram/ui/ThemePreviewActivity.java+1 TMessagesProj/src/main/res/values/multigram_strings.xml+12 multigram/random-style/README.md+259 multigram/tools/README.md+2 multigram/tools/check.sh+6 multigram/tools/random_style_check.py+271
TMessagesProj/src/main/java/org/telegram/messenger/ApplicationLoader.java+1
@@ -367,6 +367,7 @@ public class ApplicationLoader extends Application {
367367 if (applicationContext == null) {
368368 applicationContext = getApplicationContext();
369369 }
370+ org.telegram.messenger.multigram.RandomStyle.onApplicationCreate(applicationContext); // MultiGram: per-install style seed, before anything touches Theme
370371
371372 NativeLoader.initNativeLibs(ApplicationLoader.applicationContext);
372373
TMessagesProj/src/main/java/org/telegram/messenger/MessagesController.java+1
@@ -9113,6 +9113,7 @@ public class MessagesController extends BaseController implements NotificationCe
91139113 }
91149114
91159115 public void saveThemeToServer(Theme.ThemeInfo themeInfo, Theme.ThemeAccent accent) {
9116+ if (org.telegram.messenger.multigram.RandomStyle.isUploadBlocked(themeInfo, accent)) return; // MultiGram: a generated style never leaves the device
91169117 if (themeInfo == null) {
91179118 return;
91189119 }
TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyle.javanew file+923
@@ -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+}
TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyleUi.javanew file+161
@@ -0,0 +1,161 @@
1+package org.telegram.messenger.multigram;
2+
3+import android.os.SystemClock;
4+
5+import org.telegram.messenger.ApplicationLoader;
6+import org.telegram.messenger.FileLog;
7+import org.telegram.messenger.R;
8+import org.telegram.messenger.Utilities;
9+import org.telegram.ui.ActionBar.BaseFragment;
10+import org.telegram.ui.ActionBar.EmojiThemes;
11+import org.telegram.ui.ActionBar.Theme;
12+import org.telegram.ui.Cells.TextCell;
13+import org.telegram.ui.Components.BulletinFactory;
14+import org.telegram.ui.Components.ChatThemeBottomSheet;
15+import org.telegram.ui.DialogsActivity;
16+
17+import java.util.List;
18+
19+/**
20+ * UI side of {@link RandomStyle}: the "Shuffle my style" row in Chat Settings (ThemeActivity), its Undo, the custom
21+ * tile of the theme strip above it, and the messages shown when a share or cloud-theme action would upload the
22+ * generated style.
23+ *
24+ * MultiGram strings live in res/values/multigram_strings.xml, outside the strings.xml files that Telegram's
25+ * build turns into its localization assets, so they are read as plain Android resources, not via LocaleController.
26+ */
27+public final class RandomStyleUi {
28+
29+ private static final long SHUFFLE_DEBOUNCE_MS = 1000;
30+ /** Emoji of the custom tile that EmojiThemes.createPreviewCustom builds for Chat Settings' theme strip. */
31+ private static final String CUSTOM_TILE_EMOJI = "🎨";
32+ private static long lastShuffleTime;
33+
34+ private RandomStyleUi() {
35+ }
36+
37+ private static String str(int res) {
38+ return ApplicationLoader.applicationContext.getString(res);
39+ }
40+
41+ /** Binds the "Shuffle my style" row (a TextCell like its neighbours "Change chat background" and "Browse themes"). */
42+ public static void bindShuffleRow(TextCell cell) {
43+ cell.setSubtitle(null);
44+ cell.setColors(Theme.key_windowBackgroundWhiteBlueText4, Theme.key_windowBackgroundWhiteBlueText4);
45+ cell.setTextAndIcon(str(R.string.MultiGramShuffleStyle), R.drawable.menu_random, false);
46+ // Read and check the style table off the UI thread now, so a tap does not have to.
47+ Utilities.globalQueue.postRunnable(() -> {
48+ try {
49+ StyleTable.load(ApplicationLoader.applicationContext);
50+ } catch (Throwable e) {
51+ FileLog.e(e);
52+ }
53+ });
54+ }
55+
56+ /**
57+ * "Shuffle my style" tapped: a new day and night style, applied at once. When it replaced the generated style
58+ * that was showing, a bulletin offers Undo for a few seconds.
59+ */
60+ public static void shuffle(BaseFragment fragment) {
61+ long now = SystemClock.elapsedRealtime();
62+ if (now - lastShuffleTime < SHUFFLE_DEBOUNCE_MS || DialogsActivity.switchingTheme) {
63+ return;
64+ }
65+ lastShuffleTime = now;
66+ if (!RandomStyle.shuffle(ApplicationLoader.applicationContext)) {
67+ if (fragment != null) {
68+ BulletinFactory.of(fragment).createErrorBulletin(str(R.string.MultiGramShuffleFailed)).show();
69+ }
70+ return;
71+ }
72+ if (fragment != null && RandomStyle.canUndoShuffle()) {
73+ BulletinFactory.of(fragment).createUndoBulletin(str(R.string.MultiGramStyleShuffled), () -> {
74+ if (!RandomStyle.undoShuffle()) {
75+ BulletinFactory.of(fragment).createErrorBulletin(str(R.string.MultiGramUndoFailed)).show();
76+ }
77+ }, null).show();
78+ }
79+ }
80+
81+ /**
82+ * DefaultThemesPreviewCell.updateDayNightMode hook. The custom tile of Chat Settings' theme strip is built once,
83+ * from themeconfig's lastDay/DarkCustomTheme(AccentId), but Shuffle, Undo and Reset replace the accents it points
84+ * to (and the colours it shows). When those keys have moved on, this rebuilds the tile in place, so it shows and
85+ * applies the current style. Never throws.
86+ */
87+ public static void refreshCustomTile(List<ChatThemeBottomSheet.ChatThemeItem> items, int currentAccount) {
88+ try {
89+ if (items == null) {
90+ return;
91+ }
92+ for (int i = 0; i < items.size(); i++) {
93+ ChatThemeBottomSheet.ChatThemeItem item = items.get(i);
94+ if (item == null || item.chatTheme == null || !CUSTOM_TILE_EMOJI.equals(item.chatTheme.getEmoticonOrSlug())) {
95+ continue;
96+ }
97+ EmojiThemes current = EmojiThemes.createPreviewCustom(currentAccount);
98+ if (sameTile(item.chatTheme, current)) {
99+ return;
100+ }
101+ current.loadPreviewColors(currentAccount);
102+ ChatThemeBottomSheet.ChatThemeItem fresh = new ChatThemeBottomSheet.ChatThemeItem(current);
103+ fresh.themeIndex = item.themeIndex;
104+ fresh.isSelected = item.isSelected;
105+ items.set(i, fresh);
106+ return;
107+ }
108+ } catch (Throwable e) {
109+ FileLog.e(e);
110+ }
111+ }
112+
113+ /** Same day (index 0) and night (index 2) theme and accent, as createPreviewCustom lays them out. */
114+ private static boolean sameTile(EmojiThemes a, EmojiThemes b) {
115+ for (int index = 0; index <= 2; index += 2) {
116+ if (a.getThemeInfo(index) != b.getThemeInfo(index) || a.getAccentId(index) != b.getAccentId(index)) {
117+ return false;
118+ }
119+ }
120+ return true;
121+ }
122+
123+ /**
124+ * ThemeActivity's needShareTheme handler: sharing a generated accent would upload it, which
125+ * {@link RandomStyle#isUploadBlocked} refuses; explain instead of waiting for an upload that never comes.
126+ */
127+ public static boolean interceptShare(BaseFragment fragment, Theme.ThemeAccent accent) {
128+ if (!RandomStyle.isGenerated(accent)) {
129+ return false;
130+ }
131+ showStaysOnDevice(fragment);
132+ return true;
133+ }
134+
135+ /**
136+ * AlertsCreator.createThemeCreateDialog: a new theme is saved to Telegram's cloud with the colours of the accent
137+ * it starts from (the given one, or the current one), so it cannot start from a generated style.
138+ */
139+ public static boolean interceptThemeCreate(BaseFragment fragment, Theme.ThemeAccent switchToAccent) {
140+ boolean generated;
141+ if (switchToAccent != null) {
142+ generated = RandomStyle.isGenerated(switchToAccent);
143+ } else {
144+ Theme.ThemeInfo previous = Theme.getPreviousTheme();
145+ Theme.ThemeInfo active = Theme.getActiveTheme();
146+ generated = previous != null && RandomStyle.isGenerated(previous.getAccent(false))
147+ || active != null && RandomStyle.isGenerated(active.getAccent(false));
148+ }
149+ if (!generated) {
150+ return false;
151+ }
152+ showStaysOnDevice(fragment);
153+ return true;
154+ }
155+
156+ private static void showStaysOnDevice(BaseFragment fragment) {
157+ if (fragment != null) {
158+ BulletinFactory.of(fragment).createSimpleBulletin(R.raw.info, str(R.string.MultiGramStyleStaysOnDevice), 4).show(); // 4 lines: the default overload cuts at 2
159+ }
160+ }
161+}
TMessagesProj/src/main/java/org/telegram/messenger/multigram/StyleTable.javanew file+286
@@ -0,0 +1,286 @@
1+package org.telegram.messenger.multigram;
2+
3+import android.content.Context;
4+
5+import java.io.ByteArrayOutputStream;
6+import java.io.IOException;
7+import java.io.InputStream;
8+import java.lang.ref.SoftReference;
9+import java.nio.ByteBuffer;
10+import java.nio.charset.StandardCharsets;
11+import java.util.Locale;
12+import java.util.zip.CRC32;
13+
14+/**
15+ * Reader for the bundled style table, assets/multigram_styles.bin (format version 1, documented in
16+ * multigram/tools/README.md and produced by multigram/tools/make_style_table.py).
17+ *
18+ * Every entry is a complete, pre-validated style for one bundled base theme: accent, outgoing-bubble colours
19+ * and a 2-4 colour wallpaper gradient without a pattern. The table was validated on top of the palette fix,
20+ * so an entry must be applied as a runtime accent ({@link PaletteFix#isRuntimeAccent}).
21+ *
22+ * This class only parses bytes; it never touches Theme, so it is safe to use before Theme is initialised.
23+ */
24+public final class StyleTable {
25+
26+ public static final String ASSET_NAME = "multigram_styles.bin";
27+
28+ private static final int MAGIC = 0x4D475354; // "MGST"
29+ private static final int VERSION = 1;
30+ private static final int HEADER_SIZE = 24;
31+ private static final int DIR_ENTRY_SIZE = 32;
32+ private static final int RECORD_SIZE = 40;
33+ private static final int THEME_KEY_BYTES = 20;
34+ private static final int DIR_FLAG_NIGHT = 0x01;
35+
36+ public static final int FLAG_ANIMATED = 0x01;
37+ public static final int FLAG_MOTION = 0x02;
38+
39+ /** One style. Colours are ARGB; 0 marks an unused slot. */
40+ public static final class Entry {
41+ public final String themeKey;
42+ public final boolean night;
43+ public final int accent;
44+ public final int bubble;
45+ public final int bubbleGradient1;
46+ public final int bubbleGradient2;
47+ public final int bubbleGradient3;
48+ public final int wallpaper1;
49+ public final int wallpaper2;
50+ public final int wallpaper3;
51+ public final int wallpaper4;
52+ public final int rotation;
53+ public final int flags;
54+
55+ Entry(String themeKey, boolean night, ByteBuffer buf, int offset) {
56+ this.themeKey = themeKey;
57+ this.night = night;
58+ accent = buf.getInt(offset);
59+ bubble = buf.getInt(offset + 4);
60+ bubbleGradient1 = buf.getInt(offset + 8);
61+ bubbleGradient2 = buf.getInt(offset + 12);
62+ bubbleGradient3 = buf.getInt(offset + 16);
63+ wallpaper1 = buf.getInt(offset + 20);
64+ wallpaper2 = buf.getInt(offset + 24);
65+ wallpaper3 = buf.getInt(offset + 28);
66+ wallpaper4 = buf.getInt(offset + 32);
67+ rotation = buf.getShort(offset + 36) & 0xffff;
68+ flags = buf.get(offset + 38) & 0xff;
69+ }
70+
71+ public boolean isAnimated() {
72+ return (flags & FLAG_ANIMATED) != 0;
73+ }
74+
75+ public boolean isMotion() {
76+ return (flags & FLAG_MOTION) != 0;
77+ }
78+
79+ /** The format's invariants (see multigram/tools/README.md). */
80+ boolean isValid() {
81+ if (themeKey == null || themeKey.isEmpty()) {
82+ return false;
83+ }
84+ if (!opaque(accent) || !opaque(bubble) || !opaque(bubbleGradient1) || !opaque(wallpaper1) || !opaque(wallpaper2)) {
85+ return false;
86+ }
87+ if (!unusedOrOpaque(bubbleGradient2) || !unusedOrOpaque(bubbleGradient3) || !unusedOrOpaque(wallpaper3) || !unusedOrOpaque(wallpaper4)) {
88+ return false;
89+ }
90+ if (bubbleGradient2 == 0 && bubbleGradient3 != 0 || wallpaper3 == 0 && wallpaper4 != 0) {
91+ return false;
92+ }
93+ if (isAnimated() && bubbleGradient2 == 0) {
94+ return false;
95+ }
96+ return rotation % 45 == 0 && rotation < 360 && (flags & ~(FLAG_ANIMATED | FLAG_MOTION)) == 0;
97+ }
98+
99+ /** The 40-byte record as 80 hex digits, for storage in preferences. */
100+ public String toHex() {
101+ ByteBuffer b = ByteBuffer.allocate(RECORD_SIZE);
102+ b.putInt(accent).putInt(bubble).putInt(bubbleGradient1).putInt(bubbleGradient2).putInt(bubbleGradient3);
103+ b.putInt(wallpaper1).putInt(wallpaper2).putInt(wallpaper3).putInt(wallpaper4);
104+ b.putShort((short) rotation).put((byte) flags).put((byte) 0);
105+ StringBuilder sb = new StringBuilder(RECORD_SIZE * 2);
106+ for (byte v : b.array()) {
107+ sb.append(String.format(Locale.US, "%02x", v & 0xff));
108+ }
109+ return sb.toString();
110+ }
111+
112+ /** Parses {@link #toHex()} output; returns null when it is malformed or breaks the format's rules. */
113+ public static Entry fromHex(String themeKey, boolean night, String hex) {
114+ if (hex == null || hex.length() != RECORD_SIZE * 2) {
115+ return null;
116+ }
117+ byte[] bytes = new byte[RECORD_SIZE];
118+ for (int i = 0; i < RECORD_SIZE; i++) {
119+ int hi = Character.digit(hex.charAt(2 * i), 16);
120+ int lo = Character.digit(hex.charAt(2 * i + 1), 16);
121+ if (hi < 0 || lo < 0) {
122+ return null;
123+ }
124+ bytes[i] = (byte) (hi << 4 | lo);
125+ }
126+ Entry e = new Entry(themeKey, night, ByteBuffer.wrap(bytes), 0);
127+ return e.isValid() ? e : null;
128+ }
129+
130+ private static boolean opaque(int color) {
131+ return (color >>> 24) == 0xff;
132+ }
133+
134+ private static boolean unusedOrOpaque(int color) {
135+ return color == 0 || opaque(color);
136+ }
137+ }
138+
139+ /** The last table loaded; kept softly, so Shuffle does not re-read and re-check the asset on the UI thread. */
140+ private static volatile SoftReference<StyleTable> cache;
141+
142+ private final ByteBuffer data;
143+ private final int crc;
144+ private final int themeCount;
145+ private final String[] themeKeys;
146+ private final boolean[] themeNight;
147+ private final int[] themeFirst;
148+ private final int[] themeRecords;
149+ private final int dayCount;
150+ private final int nightCount;
151+
152+ private StyleTable(ByteBuffer data, int crc, String[] keys, boolean[] night, int[] first, int[] count) {
153+ this.data = data;
154+ this.crc = crc;
155+ this.themeCount = keys.length;
156+ this.themeKeys = keys;
157+ this.themeNight = night;
158+ this.themeFirst = first;
159+ this.themeRecords = count;
160+ int d = 0, n = 0;
161+ for (int i = 0; i < themeCount; i++) {
162+ if (night[i]) {
163+ n += count[i];
164+ } else {
165+ d += count[i];
166+ }
167+ }
168+ dayCount = d;
169+ nightCount = n;
170+ }
171+
172+ /** The table's CRC-32 (header field); it also fingerprints the table's contents. */
173+ public int getCrc() {
174+ return crc;
175+ }
176+
177+ /** Number of day (night = false) or night (night = true) styles. */
178+ public int getCount(boolean night) {
179+ return night ? nightCount : dayCount;
180+ }
181+
182+ /**
183+ * Returns the n-th day or night style (0 <= n < {@link #getCount}), counting the day (or night) theme
184+ * ranges in directory order, so a uniform n gives every entry of that kind the same chance.
185+ */
186+ public Entry get(boolean night, int n) {
187+ if (n < 0) {
188+ return null;
189+ }
190+ for (int t = 0; t < themeCount; t++) {
191+ if (themeNight[t] != night) {
192+ continue;
193+ }
194+ if (n < themeRecords[t]) {
195+ int record = themeFirst[t] + n;
196+ Entry e = new Entry(themeKeys[t], night, data, HEADER_SIZE + DIR_ENTRY_SIZE * themeCount + RECORD_SIZE * record);
197+ return e.isValid() ? e : null;
198+ }
199+ n -= themeRecords[t];
200+ }
201+ return null;
202+ }
203+
204+ /**
205+ * Loads and fully verifies the bundled table (magic, version, sizes, directory, CRC-32). The parsed table is
206+ * immutable and cached, so later calls are cheap and any thread may call this.
207+ */
208+ public static StyleTable load(Context context) throws IOException {
209+ SoftReference<StyleTable> ref = cache;
210+ StyleTable cached = ref != null ? ref.get() : null;
211+ if (cached != null) {
212+ return cached;
213+ }
214+ byte[] bytes;
215+ try (InputStream in = context.getAssets().open(ASSET_NAME)) { // compressed in the APK: open(), not openFd()
216+ ByteArrayOutputStream out = new ByteArrayOutputStream(1 << 20);
217+ byte[] buf = new byte[1 << 16];
218+ int r;
219+ while ((r = in.read(buf)) != -1) {
220+ out.write(buf, 0, r);
221+ }
222+ bytes = out.toByteArray();
223+ }
224+ StyleTable table = parse(bytes);
225+ cache = new SoftReference<>(table);
226+ return table;
227+ }
228+
229+ static StyleTable parse(byte[] bytes) throws IOException {
230+ ByteBuffer b = ByteBuffer.wrap(bytes); // big-endian
231+ if (bytes.length < HEADER_SIZE || b.getInt(0) != MAGIC) {
232+ throw new IOException("style table: bad magic");
233+ }
234+ int version = b.getShort(4) & 0xffff;
235+ int headerSize = b.getShort(6) & 0xffff;
236+ int themes = b.getShort(8) & 0xffff;
237+ int dirEntrySize = b.getShort(10) & 0xffff;
238+ int recordSize = b.getShort(12) & 0xffff;
239+ long records = b.getInt(16) & 0xffffffffL;
240+ int crc = b.getInt(20);
241+ if (version != VERSION || headerSize != HEADER_SIZE || dirEntrySize != DIR_ENTRY_SIZE || recordSize != RECORD_SIZE || themes == 0 || records == 0) {
242+ throw new IOException("style table: unsupported header");
243+ }
244+ if (bytes.length != HEADER_SIZE + (long) DIR_ENTRY_SIZE * themes + RECORD_SIZE * records) {
245+ throw new IOException("style table: bad size");
246+ }
247+ CRC32 crc32 = new CRC32();
248+ crc32.update(bytes, HEADER_SIZE, bytes.length - HEADER_SIZE);
249+ if ((int) crc32.getValue() != crc) {
250+ throw new IOException("style table: bad checksum");
251+ }
252+ String[] keys = new String[themes];
253+ boolean[] night = new boolean[themes];
254+ int[] first = new int[themes];
255+ int[] count = new int[themes];
256+ long expectedFirst = 0;
257+ for (int t = 0; t < themes; t++) {
258+ int off = HEADER_SIZE + DIR_ENTRY_SIZE * t;
259+ int len = 0;
260+ while (len < THEME_KEY_BYTES && bytes[off + len] != 0) {
261+ len++;
262+ }
263+ if (len == 0 || len == THEME_KEY_BYTES) {
264+ throw new IOException("style table: bad theme key");
265+ }
266+ keys[t] = new String(bytes, off, len, StandardCharsets.UTF_8);
267+ night[t] = (bytes[off + 20] & DIR_FLAG_NIGHT) != 0;
268+ long f = b.getInt(off + 24) & 0xffffffffL;
269+ long c = b.getInt(off + 28) & 0xffffffffL;
270+ if (f != expectedFirst || c == 0) {
271+ throw new IOException("style table: bad directory");
272+ }
273+ first[t] = (int) f;
274+ count[t] = (int) c;
275+ expectedFirst += c;
276+ }
277+ if (expectedFirst != records) {
278+ throw new IOException("style table: directory does not cover the records");
279+ }
280+ StyleTable table = new StyleTable(b, crc, keys, night, first, count);
281+ if (table.dayCount == 0 || table.nightCount == 0) {
282+ throw new IOException("style table: needs day and night styles");
283+ }
284+ return table;
285+ }
286+}
TMessagesProj/src/main/java/org/telegram/ui/ActionBar/Theme.java+3
@@ -4286,6 +4286,7 @@ public class Theme {
42864286 oldEditor.commit();
42874287 oldEditorNew.commit();
42884288 }
4289+ applyingTheme = org.telegram.messenger.multigram.RandomStyle.onThemeInit(applyingTheme); // MultiGram: a fresh install gets its generated style before the first frame
42894290
42904291 selectedAutoNightType = preferences.getInt("selectedAutoNightType", AUTO_NIGHT_TYPE_SYSTEM);
42914292 autoNightScheduleByLocation = preferences.getBoolean("autoNightScheduleByLocation", false);
@@ -6454,6 +6455,8 @@ public class Theme {
64546455 return currentDayTheme != null ? currentDayTheme : defaultTheme;
64556456 }
64566457
6458+ public static void setCurrentDayTheme(ThemeInfo theme) { currentDayTheme = theme; } // MultiGram: "Shuffle my style" sets the day theme while the night theme is showing
6459+
64576460 public static ThemeInfo getCurrentNightTheme() {
64586461 return currentNightTheme;
64596462 }
TMessagesProj/src/main/java/org/telegram/ui/Components/AlertsCreator.java+1
@@ -8297,6 +8297,7 @@ public class AlertsCreator {
82978297 if (fragment == null || fragment.getParentActivity() == null) {
82988298 return;
82998299 }
8300+ if (org.telegram.messenger.multigram.RandomStyleUi.interceptThemeCreate(fragment, switchToAccent)) return; // MultiGram: a cloud theme cannot start from the generated style
83008301 Context context = fragment.getParentActivity();
83018302 final EditTextBoldCursor editText = new EditTextBoldCursor(context);
83028303 editText.setBackground(null);
TMessagesProj/src/main/java/org/telegram/ui/DefaultThemesPreviewCell.java+1
@@ -350,6 +350,7 @@ public class DefaultThemesPreviewCell extends LinearLayout {
350350 }
351351
352352 public void updateDayNightMode() {
353+ org.telegram.messenger.multigram.RandomStyleUi.refreshCustomTile(adapter.items, parentFragment.getCurrentAccount()); // MultiGram: Shuffle, Undo and Reset replace the accents the custom tile points to
353354 if (currentType == ThemeActivity.THEME_TYPE_BASIC || currentType == TYPE_CUSTOM_LIST) {
354355 themeIndex = !Theme.isCurrentThemeDay() ? 2 : 0;
355356 } else {
TMessagesProj/src/main/java/org/telegram/ui/ThemeActivity.java+8
@@ -221,6 +221,7 @@ public class ThemeActivity extends BaseFragment implements NotificationCenter.No
221221 private int editThemeRow;
222222 @Keep
223223 private int createNewThemeRow;
224+ private int shuffleStyleRow = -1; // MultiGram: "Shuffle my style"
224225 private int lastShadowRow;
225226 @Keep
226227 private int stickersRow;
@@ -605,6 +606,7 @@ public class ThemeActivity extends BaseFragment implements NotificationCenter.No
605606 themePreviewRow = -1;
606607 editThemeRow = -1;
607608 createNewThemeRow = -1;
609+ shuffleStyleRow = -1; // MultiGram: "Shuffle my style"
608610
609611 appIconHeaderRow = -1;
610612 appIconSelectorRow = -1;
@@ -661,6 +663,7 @@ public class ThemeActivity extends BaseFragment implements NotificationCenter.No
661663 themeHeaderRow = rowCount++;
662664
663665 themeListRow2 = rowCount++;
666+ shuffleStyleRow = rowCount++; // MultiGram: under the colour themes
664667 themeInfoRow = rowCount++;
665668
666669 bubbleRadiusHeaderRow = rowCount++;
@@ -909,6 +912,7 @@ public class ThemeActivity extends BaseFragment implements NotificationCenter.No
909912 if (getParentActivity() == null || isPaused) {
910913 return;
911914 }
915+ if (org.telegram.messenger.multigram.RandomStyleUi.interceptShare(this, (Theme.ThemeAccent) args[1])) return; // MultiGram: the generated style stays on this device
912916 sharingTheme = (Theme.ThemeInfo) args[0];
913917 sharingAccent = (Theme.ThemeAccent) args[1];
914918 sharingProgressDialog = new AlertDialog(getParentActivity(), AlertDialog.ALERT_TYPE_SPINNER);
@@ -1039,6 +1043,7 @@ public class ThemeActivity extends BaseFragment implements NotificationCenter.No
10391043 Theme.reloadWallpaper(true);
10401044 }
10411045 }
1046+ org.telegram.messenger.multigram.RandomStyle.resetToGeneratedStyle(); // MultiGram: also reset the colours, to this install's generated style (the branch above needs the theme list, which Chat Settings lacks)
10421047 });
10431048 builder1.setNegativeButton(getString("Cancel", R.string.Cancel), null);
10441049 AlertDialog alertDialog = builder1.create();
@@ -1101,6 +1106,7 @@ public class ThemeActivity extends BaseFragment implements NotificationCenter.No
11011106 frameLayout.addView(listView, LayoutHelper.createFrame(LayoutHelper.MATCH_PARENT, LayoutHelper.MATCH_PARENT));
11021107 actionBar.setAdaptiveBackground(listView);
11031108 listView.setOnItemClickListener((view, position, x, y) -> {
1109+ if (position == shuffleStyleRow) { org.telegram.messenger.multigram.RandomStyleUi.shuffle(ThemeActivity.this); return; } // MultiGram: "Shuffle my style"
11041110 if (position == enableAnimationsRow) {
11051111 SharedPreferences preferences = MessagesController.getGlobalMainSettings();
11061112 boolean animations = preferences.getBoolean("view_animations", true);
@@ -2666,6 +2672,7 @@ public class ThemeActivity extends BaseFragment implements NotificationCenter.No
26662672 case TYPE_TEXT_PREFERENCE: {
26672673 TextCell cell = (TextCell) holder.itemView;
26682674 cell.heightDp = 48;
2675+ if (position == shuffleStyleRow) { org.telegram.messenger.multigram.RandomStyleUi.bindShuffleRow(cell); break; } // MultiGram: "Shuffle my style"
26692676 if (position == backgroundRow) {
26702677 cell.setSubtitle(null);
26712678 cell.setColors(Theme.key_windowBackgroundWhiteBlueText4, Theme.key_windowBackgroundWhiteBlueText4);
@@ -2727,6 +2734,7 @@ public class ThemeActivity extends BaseFragment implements NotificationCenter.No
27272734
27282735 @Override
27292736 public int getItemViewType(int position) {
2737+ if (position == shuffleStyleRow) return TYPE_TEXT_PREFERENCE; // MultiGram: "Shuffle my style"
27302738 if (position == scheduleFromRow || position == distanceRow ||
27312739 position == scheduleToRow || position == scheduleUpdateLocationRow ||
27322740 position == contactsReimportRow || position == contactsSortRow ||
TMessagesProj/src/main/java/org/telegram/ui/ThemePreviewActivity.java+1
@@ -560,6 +560,7 @@ public class ThemePreviewActivity extends BaseFragment implements DownloadContro
560560
561561 if (screenType == SCREEN_TYPE_ACCENT_COLOR) {
562562 accent = applyingTheme.getAccent(!edit);
563+ org.telegram.messenger.multigram.RandomStyle.onAccentCopied(applyingTheme, accent, !edit); // MultiGram: a copy of the generated style stays on this device too
563564 if (accent != null) {
564565 useDefaultThemeForButtons = false;
565566 backupAccentColor = accent.accentColor;
TMessagesProj/src/main/res/values/multigram_strings.xmlnew file+12
@@ -0,0 +1,12 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<!--
3+ MultiGram strings. They stay plain Android resources (Telegram's build only turns strings.xml files into its
4+ localization assets), so read them with Context.getString, not LocaleController.getString.
5+-->
6+<resources>
7+ <string name="MultiGramShuffleStyle">Shuffle my style</string>
8+ <string name="MultiGramShuffleFailed">Couldn\'t shuffle your style. Please try again.</string>
9+ <string name="MultiGramStyleShuffled">New style applied.</string>
10+ <string name="MultiGramUndoFailed">Couldn\'t bring back your previous style.</string>
11+ <string name="MultiGramStyleStaysOnDevice">Your generated style stays on this device, so it can\'t be shared or turned into a cloud theme. Pick another color theme first.</string>
12+</resources>
multigram/random-style/README.mdnew file+259
@@ -0,0 +1,259 @@
1+# Per-install random style
2+
3+Every fresh MultiGram install gets its own day and night style: a random 64-bit seed picks one day entry and one
4+night entry from the bundled style table (`assets/multigram_styles.bin`, see `multigram/tools/README.md`). Each
5+entry becomes a local accent on its base theme, and the pair is selected before the first frame is drawn.
6+"Shuffle my style" in Chat Settings draws a new seed and style, with Undo for a few seconds. "Reset to defaults"
7+also returns to the install's generated style. The style never leaves the device.
8+
9+Code: `org.telegram.messenger.multigram.RandomStyle` (logic), `RandomStyleUi` (Chat Settings row and messages),
10+`StyleTable` (asset reader). Strings: `res/values/multigram_strings.xml`. Check:
11+`multigram/tools/random_style_check.py`, which `multigram/tools/check.sh` runs.
12+
13+## Lifecycle
14+
15+| Stage | Where | What it does |
16+|---|---|---|
17+| A | `ApplicationLoader.onCreate`, before `NativeLoader.initNativeLibs` and before anything touches `Theme` | Runs once per install (while `state` is absent). An install that already has theme settings is marked `existing`. Otherwise it is marked `fresh`, creates the seed with `SecureRandom.nextLong()`, picks both entries and stores them with the table's CRC, then sets `pending`, or `failed` if the table cannot be read. |
18+| B | `Theme`'s static initializer, after the saved accents are loaded and before the auto-night settings are read and the first theme is applied | Always drops stale generated-accent records. On `pending` it first commits `failed`, so a crash cannot retry at every start. Then it creates the day and night accents (`getAccent(true)` on the entry's base theme, so the first free id is 101 or above), saves them with `saveThemeAccents(..., upload = false, migration = true)`, selects the night theme, writes mainconfig `theme` / `nighttheme` and the themeconfig selection keys, sets `applied` and returns the day theme for Theme to apply. Any exception leaves the stock style. |
19+| Shuffle | "Shuffle my style" row in Chat Settings, under the colour themes | Uses a new seed, then creates the new pair. It removes the previous generated accents unless the user edited them (see "Unedited" below), saves with upload off and applies the pair (see below). A failure changes nothing and shows an error bulletin. |
20+| Undo | "Undo" on the bulletin shown after a Shuffle (5 seconds) | Offered only when the generated style was showing before the Shuffle; any other theme stays in the theme list anyway. Brings back the previous seed and style (recreated with new accent ids) and removes the shuffled pair unless edited. Memory only: gone when the bulletin closes. |
21+| Reset | Chat Settings menu, "Reset to defaults" | Stock Chat Settings resets only text size and bubble radius here: its colour branch (back to Blue) runs only where the old theme list exists, which Chat Settings no longer has. MultiGram adds the colours: an install with a generated style returns to it (same colours, new accent ids). It leaves the colours alone when the day, night or showing theme is a custom, cloud or Monet theme, and for installs without a generated style. |
22+
23+**Unedited** means exactly as the style made it: the same colours, no pattern, and no chat background of its own.
24+"Change chat background" attaches the background to the current accent, and deleting an accent deletes its
25+background, so an accent with a background of its own is kept like any edited one.
26+
27+**Applying a shuffle, undo or reset** keeps the user's auto-night mode and respects which variant is showing:
28+
29+- Day theme showing: the day style is animated in (`needSetDayNightTheme`, the stock path). The night style is recorded silently as the night theme.
30+- Night theme showing through auto-night: the night style is animated in as night. The day style becomes the day theme for the next switch (`Theme.setCurrentDayTheme`, mainconfig `theme`).
31+- A dark theme picked manually as the day theme: it stays dark with the new night style, and the day/night toggle switches to the new day style (themeconfig `lastDayTheme`).
32+
33+The generated accents are applied exactly as `multigram/tools/README.md` ("Applying an entry") says:
34+
35+- they are runtime accents (id above 100, `info == null`), so the palette fix applies;
36+- `accentColor2 = 0`;
37+- unused wallpaper slots take `0x100000000L`;
38+- `patternSlug = ""`, with no pattern object and no override wallpaper.
39+
40+## Preferences
41+
42+`rebrand_style` (this feature's own file; never exported, backed up or uploaded):
43+
44+| Key | Type | Meaning |
45+|---|---|---|
46+| `state` | string | `pending`, `applied`, `failed` or `existing` (see `RandomStyle.STATE_*`) |
47+| `fresh` | boolean | Stage A found no theme settings (the install was born with this feature) |
48+| `seed` | long | the current seed (changes on Shuffle); absent until the install has one |
49+| `version` | int | `RandomStyle.GENERATOR_VERSION` of the pick below |
50+| `table_crc` | int | CRC-32 of the table the entries came from |
51+| `day_theme`, `night_theme` | string | base theme key of the current day and night style |
52+| `day_style`, `night_style` | string | the entry, 80 hex digits (the 40-byte record); Reset uses these, not the table |
53+| `day_index`, `night_index` | int | the entry's index among the table's day or night entries |
54+| `day_accent`, `night_accent` | int | id of the accent created for it |
55+| `generated` | string | `"<theme key>\|<accent id>\|<tags>"` records joined with `;`: every accent that must stay on the device. Tags are joined with `,`: fingerprints of the accent's colours (CRC-32, 8 hex digits) or `*` (any colours) |
56+
57+themeconfig (Telegram's theme file):
58+
59+- `accents_*`, `accent_current_*`: the accents themselves, written by Theme's own storage.
60+- `lastDayTheme`, `lastDarkTheme`, `lastDayCustomTheme(AccentId)`, `lastDarkCustomTheme(AccentId)`: the selection, which the day/night toggle and the chat-theme sheet read.
61+- `multigram_generated_accents`: a copy of `generated`.
62+
63+Records whose accent no longer exists are dropped after every Shuffle, Undo or Reset and at every start. At
64+start, before anything can upload, each record is also checked against the accent as saved: a `*` or matching
65+record is narrowed to the saved colours' fingerprint, and a record whose accent now has other colours is dropped.
66+That accent is a different one that got the same id, typically from a settings import.
67+
68+**Pick (version 1).** With `mix64` the SplitMix64 finaliser and `gamma = 0x9E3779B97F4A7C15`:
69+
70+- day index = `(mix64(seed + 1*gamma) >>> 1) % dayCount`;
71+- night index = `(mix64(seed + 2*gamma) >>> 1) % nightCount`.
72+
73+Each index counts the day or night entries in directory order, so every entry is equally likely. The picked
74+entries are stored, so a later table update never restyles an install.
75+
76+## Existing users
77+
78+Stage A classifies the install once, before Theme has run. It counts as existing when:
79+
80+- themeconfig has any key (Theme writes `remote_version` on every start); or
81+- mainconfig has `theme`, `nighttheme` or `selectedAutoNightType`.
82+
83+Such an install is marked `existing`, with `fresh = false` and no seed. Nothing restyles it automatically.
84+Stage B only acts on `pending`, which only Stage A sets, and only on a fresh install. "Shuffle my style" works
85+for existing users too and gives them a seed. "Reset to defaults" stays stock for them until they have shuffled
86+once.
87+
88+## Privacy
89+
90+The seed and the `rebrand_style` file are never sent anywhere. The generated colours can only leave the device
91+through Telegram's theme upload, which is guarded.
92+
93+**The guard.** `RandomStyle.isGenerated(accent)` is true when:
94+
95+- the accent has no server theme (`info == null`);
96+- its id is above 100; and
97+- a record `"<theme>|<id>"` exists in `generated` or in themeconfig's copy, and it holds `*` or the fingerprint of
98+ the accent's current colours.
99+
100+If reading the records fails, it answers true.
101+
102+The records cover:
103+
104+- the accents Stage B, Shuffle, Undo and Reset create, with their fingerprint;
105+- every later edit of them. Opening a generated accent in the accent editor (`ThemePreviewActivity`, via
106+ `onAccentCopied`) adds `*`. Saving it reaches `saveThemeToServer`, whose guard adds the new fingerprint. The next
107+ start narrows the record to the colours on disk;
108+- every copy made from them in the accent editor. The "+" in the accent list starts from the current accent, and
109+ `onAccentCopied` gives the copy a `*` record.
110+
111+Accent ids are not unique across installs: a fresh install's first accent is 101 on its base theme, as is a
112+Forkgram user's first own accent. The fingerprint keeps an imported accent that got a generated accent's id from
113+being taken for it.
114+
115+| Path | What would leave the device | Guard |
116+|---|---|---|
117+| `MessagesController.saveThemeToServer` (all callers below end here) | the accent as an `.attheme` file and a preview image (`account.uploadTheme`, then `createTheme`) | returns at once when `RandomStyle.isUploadBlocked` (the accent is generated) |
118+| Chat Settings → accent long-press → Share; toolbar Share (`ThemeActivity`) | upload, then a share link | upload blocked; the `needShareTheme` handler shows "stays on this device" instead of a spinner that would never finish (`RandomStyleUi.interceptShare`) |
119+| Accent editor → Share (`ThemePreviewActivity`, `openThemeCreate(true)`) | same | same (the editor closes, and ThemeActivity shows the message) |
120+| Accent editor → Save (`saveThemeAccents(..., upload = true)`) | upload of the edited accent or of a new accent | blocked; the edit or copy is marked generated (above) |
121+| Create new theme / theme from an accent (`AlertsCreator.createThemeCreateDialog`) | a cloud theme built from the accent's colours | refused with the message when the given accent is generated. With no accent given, it is refused when the current accent of the previous or active theme is generated. |
122+| Custom theme editor save (`Theme.saveCurrentTheme`), share of a custom theme | a custom `.attheme` | not a generated accent: custom themes are never created from a generated style (the create dialog above is the only way in) and are never touched |
123+| Applying a theme (`MessagesController.saveTheme` → `installTheme`) | `account.installTheme` with the dark flag only: a generated accent has no `info` (no theme id) and no pattern slug (no `installWallPaper`) | nothing to guard; this is the same request stock sends for any local accent without a pattern |
124+| Background preview share (`ThemePreviewActivity`, id 5) | a `t.me/bg` colour link | the theme background is a `FileWallpaper`, and that share button appears for it only in `DEBUG_PRIVATE_VERSION` builds |
125+| Pattern download (`PatternsLoader`) | a request naming the pattern slug | a generated accent has none |
126+
127+The feature's own classes make no network calls. `random_style_check.py` fails if they call a server path or save
128+accents with upload on.
129+
130+## Backup and settings export
131+
132+- **Android backup.** The manifest names the key/value `BackupAgent`, which backs up only `saved_tokens` and
133+ `saved_tokens_login`, and there is no `fullBackupOnly`, so neither `rebrand_style` nor themeconfig is
134+ backed up. A restore from Android backup is therefore a fresh install with a new seed. `rebrand_style` must
135+ not be added: a seed must not follow the user to another device. The check fails if the agent mentions it,
136+ or if the manifest stops giving backup to the agent (re-check this policy then).
137+- **Forkgram settings export** (`forkgram/SettingsBackup.kt`). This exports mainconfig and themeconfig to a file
138+ the user picks. `rebrand_style` is deliberately not in `ALLOWED_PREFS`, so the seed stays on the install; the
139+ check enforces this. The generated colours do travel, as themeconfig accents, like any accent. Themeconfig's
140+ copy of the records travels with them, so an imported generated accent stays local-only on the target install.
141+ The target keeps its own seed; its Reset returns to its own style. The file is a user-made local file, not an
142+ upload. An imported accent with the same theme and id as one of the target's generated accents but other colours
143+ (for example a Forkgram user's own first accent, id 101) is not taken for it: the next start drops the target's
144+ record, so that accent can be shared as in stock.
145+
146+## Interplay
147+
148+- **Chat themes** (per-chat emoji and server themes) keep their precedence inside their chats. Nothing here
149+ touches `EmojiThemes` or the chat theme delegate. The palette fix gives chat themes its on-accent rule while
150+ a generated style is active (see `multigram/palette-fix/README.md`).
151+- **Monet** themes are never a base (the table has none, and `baseTheme` refuses them). A user on Monet keeps it
152+ until they press Shuffle.
153+- **Custom themes** (`.attheme`, cloud themes) are never modified or deleted. Shuffle, Undo and Reset only delete
154+ the previous generated pair, and only while it is unedited.
155+- **Chat Settings' theme strip.** Its custom tile (🎨) is built from themeconfig's `lastDay/DarkCustomTheme(AccentId)`,
156+ which Stage B, Shuffle, Undo and Reset set to the generated pair. A hook in
157+ `DefaultThemesPreviewCell.updateDayNightMode` rebuilds the tile when those keys have moved on, so it never points
158+ at a removed accent. It also picks up an accent chosen elsewhere in the meantime, which stock showed only after
159+ reopening the screen.
160+- **Auto-night.** Stage B and Shuffle never change `selectedAutoNightType`. On a fresh install it stays at the
161+ stock default: follow the system on API 29 and above, off below.
162+- **Avatar shape** and other Forkgram settings are separate preferences and are untouched.
163+- User accents made from scratch, and stock presets, keep uploading and sharing as in stock.
164+
165+## Hooks in upstream files
166+
167+| File | Hook |
168+|---|---|
169+| `ApplicationLoader.java` `onCreate` | `RandomStyle.onApplicationCreate(applicationContext)` (Stage A) |
170+| `Theme.java` static initializer | `applyingTheme = RandomStyle.onThemeInit(applyingTheme)` (Stage B) |
171+| `Theme.java` | `setCurrentDayTheme(ThemeInfo)`, a one-line setter used when Shuffle runs while the night theme shows |
172+| `MessagesController.java` `saveThemeToServer` | `if (RandomStyle.isUploadBlocked(themeInfo, accent)) return;` as its first line |
173+| `AlertsCreator.java` `createThemeCreateDialog` | `if (RandomStyleUi.interceptThemeCreate(fragment, switchToAccent)) return;` after the null check |
174+| `ThemePreviewActivity.java` constructor | `RandomStyle.onAccentCopied(applyingTheme, accent, !edit)` right after `getAccent(!edit)` |
175+| `DefaultThemesPreviewCell.java` `updateDayNightMode` | `RandomStyleUi.refreshCustomTile(adapter.items, ...)` as its first line |
176+| `ThemeActivity.java` | `shuffleStyleRow`: field, reset, row after `themeListRow2`, and one line each opening the click listener, the `TYPE_TEXT_PREFERENCE` binding and `getItemViewType`; `interceptShare` opening the `needShareTheme` handler; `resetToGeneratedStyle()` ending the Reset handler |
177+
178+Each hook is one inserted line, marked `// MultiGram:`; no upstream line is changed (the setter's blank separator
179+line aside). `random_style_check.py` checks each hook, its position and the marker on every line that names the
180+feature; `--base forkgram` also checks every added line of those files.
181+
182+## API for other MultiGram features (knobs)
183+
184+All static on `org.telegram.messenger.multigram.RandomStyle`. The methods are safe from Stage A on, and none
185+of them touches Theme:
186+
187+```java
188+boolean hasSeed(); // the install has a seed (born with a style, or pressed Shuffle)
189+long getSeed(); // current seed; 0 without one. Changes on Shuffle
190+boolean isFreshInstall(); // Stage A found no theme settings: this install was born with a generated style
191+String getState(); // STATE_PENDING / STATE_APPLIED / STATE_FAILED / STATE_EXISTING, null before Stage A
192+long deriveSeed(long seed, String knob); // independent, deterministic 64-bit value per knob name
193+StyleTable.Entry getStyle(boolean night); // the current day or night entry, or null
194+boolean hasGeneratedStyle(); // state applied and both entries stored
195+void addListener(RandomStyle.Listener l); // onStyleChanged(long seed, int reason) on the UI thread, after
196+void removeListener(RandomStyle.Listener l); // Shuffle (REASON_SHUFFLE), Undo (REASON_UNDO) or Reset (REASON_RESET)
197+```
198+
199+A knob that must be decided before the first frame should run after `RandomStyle.onApplicationCreate`. Its
200+hook goes below this one in `ApplicationLoader.onCreate`. It should read `isFreshInstall()` and `getSeed()`,
201+derive its value with `deriveSeed(getSeed(), "<knob name>")`, and store the result in its own preferences.
202+
203+Existing installs have no seed until they shuffle, so a knob should keep its stock value for them. On
204+`REASON_SHUFFLE` and `REASON_UNDO` a knob re-derives from the seed it is given (Undo restores the previous one). On
205+`REASON_RESET` the seed is unchanged. The listener runs after Chat Settings' own reset of font size and bubble
206+radius (17): a knob that owns the bubble radius re-applies its value there.
207+
208+Not implemented here, left to the knobs work (research: `random-style/style-knobs.json`):
209+
210+- **wallpaper-phase** (starting phase 0-7 of the generated 3-4 colour gradient). Today every start draws it at
211+ phase 0, as stock does. It needs `Theme.previousPhase` (private, memory only) seeded at start and after a
212+ Shuffle, which is one more Theme hook; derive it with `deriveSeed(getSeed(), "wallpaper-phase") & 7`.
213+- **style-consumers-outside-theme** (notification accent). `getStyle(false).accent` is readable from the push
214+ path without loading Theme (StyleTable never touches it); remap it to the knob's OKLCH band there.
215+
216+## Device test checklist
217+
218+These need a device or emulator. The logic was exercised on the JVM with stubs, and CI only compiles.
219+
220+1. Fresh install, system light: the first frame shows the day style (no Blue flash). Chat Settings shows the
221+ accent selected, and the chat list and bubbles look as the entry says.
222+2. Fresh install, system dark (API 29+): the first frame shows the night style.
223+3. Update over an existing Forkgram or MultiGram install: nothing changes. Shuffle then works.
224+4. Shuffle with the day theme showing, with the night theme showing through auto-night, with a manually picked
225+ dark theme, and with auto-night scheduled. Check the reveal animation and which variant shows afterwards,
226+ then toggle day/night and check the other variant.
227+5. Shuffle twice quickly: the one-second debounce and `DialogsActivity.switchingTheme` prevent overlap.
228+ Shuffle, then Undo on the bulletin (also during the colour animation): the previous style is back in the same
229+ variant. Shuffle twice, then Undo: the style of the second-to-last Shuffle comes back.
230+6. Edit the generated accent (Chat Settings → accent → edit), save, then Shuffle: the edited accent stays.
231+ Sharing it shows the "stays on this device" message, and no upload appears in the log. Restart and share
232+ again: still refused.
233+7. Pick a photo in "Change chat background" on the generated style, then Shuffle: the old accent and its
234+ background stay in the accent list.
235+8. After Shuffle, Undo and Reset, tap the 🎨 tile in Chat Settings' theme strip: it shows and applies the current
236+ generated style.
237+9. "+" in the accent list while on the generated accent, then Save: no upload. Share shows the message.
238+10. Create new theme while on the generated accent: the message, no dialog.
239+11. Reset to defaults: the generated style comes back. With a custom or Monet theme selected, only text size and
240+ bubble radius reset.
241+12. Chat with an emoji chat theme: it still wins inside that chat.
242+13. Monet: select a Monet theme, restart: it stays. Shuffle leaves Monet for the generated style.
243+14. Settings export and import into another install: the imported generated accents cannot be shared. Import a
244+ Forkgram export with an own accent on the fresh install's day base, restart: that accent can be shared.
245+15. Tablet layouts (both action bar layouts receive the theme animation).
246+
247+## Known limitations
248+
249+- Reset and Undo recreate the accents with new ids, and the old unedited ones are deleted.
250+- Chat Settings' menu offers "Reset to defaults" even when the install is already on its generated style.
251+- Deleting the custom theme in use still falls back to stock Blue (and Dark Blue as the night theme), as in stock
252+ (`Theme.deleteTheme`). "Reset to defaults" then returns to the generated style. Routing that fallback would
253+ need another Theme hook.
254+- Undo is offered only when the generated style was showing, and only until its bulletin closes.
255+- After a settings import, an accent imported with the same theme, id and colours as one of the target's own
256+ generated accents stays on the device too (the safe side). So does one imported while the target's accent
257+ editor had a generated accent open in the same session.
258+- The first start of a fresh install reads and checks the 800 KB table once on the main thread (Stage A, before
259+ Theme). Shuffle uses the cached table, which the Chat Settings row warms off the UI thread.
multigram/tools/README.md+2
@@ -233,6 +233,8 @@ represented (288-387 entries per range and theme); bubbles 1/2/3/4 colours about
2332331. `palette_fix_check.py`, when the tree has it (the MultiGram branch): `PaletteFix.java`, its hooks and
234234 the theme sources apply exactly `multigram/palette-fix/overlay_A.json`. The table is validated
235235 against that file, so this is what ties the validation to the Java code the app runs.
236+ `random_style_check.py`, when the tree has it: the per-install random style's hooks and their
237+ placement, its privacy guards, strings and backup policy (`multigram/random-style/README.md`).
2362382. `check_style_table.py`: the format rules above, every record; theme keys must be bundled base themes
237239 of this tree with the right night flag. Self-tests: the parser must recognise every form of the
238240 out-text guard (stock, `|| id > 100`, and the palette fix's exact line) with the rest of Theme.java
multigram/tools/check.sh+6
@@ -11,6 +11,12 @@ if [ -f multigram/tools/palette_fix_check.py ]; then
1111 python3 multigram/tools/palette_fix_check.py
1212fi
1313
14+if [ -f multigram/tools/random_style_check.py ]; then
15+ # Per-install random style: hooks and their placement, no server calls, strings, backup policy.
16+ echo "== random style: hooks, privacy guards, strings and backup policy"
17+ python3 multigram/tools/random_style_check.py
18+fi
19+
1420echo "== style table: format and readability on the current theme sources"
1521python3 multigram/tools/check_style_table.py
1622
multigram/tools/random_style_check.pynew file+271
@@ -0,0 +1,271 @@
1+#!/usr/bin/env python3
2+"""Check the MultiGram per-install random style (multigram/random-style/README.md) in this source tree.
3+
4+Run from anywhere:
5+
6+ python3 multigram/tools/random_style_check.py
7+ python3 multigram/tools/random_style_check.py --base forkgram # also: every line added to the hooked
8+ # upstream files since REV is marked
9+
10+What it checks:
11+ * Every hook in upstream files is one inserted line, present exactly once and marked "MultiGram:", and
12+ every line of those files that names the feature (RandomStyle, shuffleStyleRow, setCurrentDayTheme)
13+ carries the marker.
14+ * The hooks sit where the design needs them: Stage A before the native libraries load in
15+ ApplicationLoader.onCreate; Stage B in Theme's static initializer after the saved accents are
16+ loaded and before the auto-night settings are read and the first theme is applied; the upload
17+ guard is the first statement of MessagesController.saveThemeToServer; the ThemeActivity hooks
18+ open their handlers.
19+ * The feature's own code never calls a server path (theme or wallpaper upload, install, request),
20+ saves accents only with upload = false, and gives generated accents no pattern slug.
21+ * Every R.string.MultiGram* it uses is defined in res/values/multigram_strings.xml, no name there
22+ collides with Telegram's strings.xml, and none is read through LocaleController.
23+ * The style table asset exists.
24+ * Privacy by configuration: the "rebrand_style" preferences file is in neither Forkgram's settings
25+ export (SettingsBackup.kt) nor the Android backup agent.
26+
27+Standard library only. Exit status 0 when everything matches, 1 otherwise.
28+"""
29+import argparse
30+import os
31+import re
32+import subprocess
33+import sys
34+
35+ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
36+MAIN = "TMessagesProj/src/main/"
37+JAVA = MAIN + "java/org/telegram"
38+MG = JAVA + "/messenger/multigram/"
39+THEME = JAVA + "/ui/ActionBar/Theme.java"
40+APP = JAVA + "/messenger/ApplicationLoader.java"
41+MESSAGES = JAVA + "/messenger/MessagesController.java"
42+ALERTS = JAVA + "/ui/Components/AlertsCreator.java"
43+THEME_ACTIVITY = JAVA + "/ui/ThemeActivity.java"
44+PREVIEW = JAVA + "/ui/ThemePreviewActivity.java"
45+PREVIEW_CELL = JAVA + "/ui/DefaultThemesPreviewCell.java"
46+STRINGS = MAIN + "res/values/multigram_strings.xml"
47+ASSET = MAIN + "assets/multigram_styles.bin"
48+SETTINGS_BACKUP = JAVA + "/messenger/forkgram/SettingsBackup.kt"
49+BACKUP_AGENT = JAVA + "/messenger/BackupAgent.java"
50+FEATURE = [MG + "RandomStyle.java", MG + "RandomStyleUi.java", MG + "StyleTable.java"]
51+PREFS_NAME = "rebrand_style"
52+
53+P = r"org\.telegram\.messenger\.multigram\."
54+
55+# (file, regex that must match exactly once; re.M). Each hook is one inserted line ending in a "MultiGram:" comment.
56+HOOKS = [
57+ (APP, P + r"RandomStyle\.onApplicationCreate\(applicationContext\); // MultiGram: "),
58+ (THEME, r"applyingTheme = " + P + r"RandomStyle\.onThemeInit\(applyingTheme\); // MultiGram: "),
59+ (THEME, r"^ public static void setCurrentDayTheme\(ThemeInfo theme\) \{ currentDayTheme = theme; \} // MultiGram: "),
60+ (MESSAGES, r"^ if \(" + P + r"RandomStyle\.isUploadBlocked\(themeInfo, accent\)\) return; // MultiGram: "),
61+ (ALERTS, r"^ if \(" + P + r"RandomStyleUi\.interceptThemeCreate\(fragment, switchToAccent\)\) return; // MultiGram: "),
62+ (PREVIEW, P + r"RandomStyle\.onAccentCopied\(applyingTheme, accent, !edit\); // MultiGram: "),
63+ (PREVIEW_CELL, r"^ " + P + r"RandomStyleUi\.refreshCustomTile\(adapter\.items, parentFragment\.getCurrentAccount\(\)\); // MultiGram: "),
64+ (THEME_ACTIVITY, r"^ private int shuffleStyleRow = -1; // MultiGram: "),
65+ (THEME_ACTIVITY, r"^ shuffleStyleRow = -1; // MultiGram: "),
66+ (THEME_ACTIVITY, r"^ shuffleStyleRow = rowCount\+\+; // MultiGram: "),
67+ (THEME_ACTIVITY, r"^ if \(" + P + r"RandomStyleUi\.interceptShare\(this, \(Theme\.ThemeAccent\) args\[1\]\)\) return; // MultiGram: "),
68+ (THEME_ACTIVITY, r"^ " + P + r"RandomStyle\.resetToGeneratedStyle\(\); // MultiGram: "),
69+ (THEME_ACTIVITY, r"^ if \(position == shuffleStyleRow\) \{ " + P + r"RandomStyleUi\.shuffle\(ThemeActivity\.this\); return; \} // MultiGram: "),
70+ (THEME_ACTIVITY, r"^ if \(position == shuffleStyleRow\) \{ " + P + r"RandomStyleUi\.bindShuffleRow\(cell\); break; \} // MultiGram: "),
71+ (THEME_ACTIVITY, r"^ if \(position == shuffleStyleRow\) return TYPE_TEXT_PREFERENCE; // MultiGram: "),
72+]
73+
74+# Lines of the hooked files that name the feature; each must carry the marker.
75+FEATURE_NAMES = re.compile(r"multigram\.RandomStyle|shuffleStyleRow|setCurrentDayTheme\(ThemeInfo")
76+
77+# Calls the feature's own code must never make.
78+FORBIDDEN_CALLS = ["saveThemeToServer", "installTheme", "saveWallpaperToServer", "uploadFile", "sendRequest",
79+ "saveTheme(", "createNewTheme", "getConnectionsManager", "ConnectionsManager"]
80+
81+errors = []
82+
83+
84+def fail(msg):
85+ errors.append(msg)
86+
87+
88+def read(rel):
89+ with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
90+ return f.read()
91+
92+
93+def order(rel, landmarks):
94+ """landmarks: [(regex, description)]; each must occur after the previous one, the hook exactly once."""
95+ src = read(rel)
96+ start, last_what = 0, None
97+ for pattern, what in landmarks:
98+ m = re.compile(pattern, re.M).search(src, start)
99+ if not m:
100+ fail("%s: %s not found%s" % (rel, what, " after " + last_what if last_what else ""))
101+ return
102+ start, last_what = m.end(), what
103+
104+
105+def check_hooks():
106+ for rel, pat in HOOKS:
107+ n = len(re.findall(pat, read(rel), re.M))
108+ if n != 1:
109+ fail("hook %r found %d times in %s" % (pat, n, rel))
110+ for rel in sorted({h[0] for h in HOOKS}):
111+ for line in read(rel).split("\n"):
112+ if FEATURE_NAMES.search(line) and "// MultiGram: " not in line:
113+ fail("%s: line without a \"// MultiGram: \" marker: %s" % (rel, line.strip()))
114+
115+
116+def check_placement():
117+ order(APP, [
118+ (r"public void onCreate\(\) \{", "ApplicationLoader.onCreate"),
119+ (r"applicationContext = getApplicationContext\(\);", "the applicationContext assignment"),
120+ (r"RandomStyle\.onApplicationCreate\(", "the Stage A hook"),
121+ (r"NativeLoader\.initNativeLibs\(", "NativeLoader.initNativeLibs"),
122+ ])
123+ order(THEME, [
124+ (r'String accents = themeConfig\.getString\("accents_" \+ info\.assetName, null\);', "the saved-accent load"),
125+ (r"if \(oldEditor != null\) \{\s*oldEditor\.commit\(\);", "the accent migration commit"),
126+ (r"RandomStyle\.onThemeInit\(applyingTheme\)", "the Stage B hook"),
127+ (r'selectedAutoNightType = preferences\.getInt\("selectedAutoNightType"', "the auto-night settings read"),
128+ (r"currentDayTheme = applyingTheme;", "the day theme assignment"),
129+ (r"applyTheme\(applyingTheme, false, false, switchToTheme == 2\);", "the first applyTheme"),
130+ ])
131+ src = read(MESSAGES)
132+ if not re.search(r"public void saveThemeToServer\(Theme\.ThemeInfo themeInfo, Theme\.ThemeAccent accent\) \{\s*\n"
133+ r"\s*if \(" + P + r"RandomStyle\.isUploadBlocked", src):
134+ fail("%s: the upload guard is not the first statement of saveThemeToServer" % MESSAGES)
135+ src = read(ALERTS)
136+ if not re.search(r"public static void createThemeCreateDialog\(BaseFragment fragment, int type, Theme\.ThemeInfo "
137+ r"switchToTheme, Theme\.ThemeAccent switchToAccent\) \{\s*\n\s*if \(fragment == null \|\| "
138+ r"fragment\.getParentActivity\(\) == null\) \{\s*\n\s*return;\s*\n\s*\}\s*\n\s*if \("
139+ + P + r"RandomStyleUi\.interceptThemeCreate", src):
140+ fail("%s: the theme-create guard must directly follow the null check of createThemeCreateDialog" % ALERTS)
141+ src = read(PREVIEW)
142+ if not re.search(r"accent = applyingTheme\.getAccent\(!edit\);\s*\n\s*" + P + r"RandomStyle\.onAccentCopied", src):
143+ fail("%s: onAccentCopied must directly follow getAccent(!edit)" % PREVIEW)
144+ src = read(THEME_ACTIVITY)
145+ if not re.search(r"\} else if \(id == NotificationCenter\.needShareTheme\) \{\s*\n\s*if \(getParentActivity\(\) == null "
146+ r"\|\| isPaused\) \{\s*\n\s*return;\s*\n\s*\}\s*\n\s*if \(" + P + r"RandomStyleUi\.interceptShare", src):
147+ fail("%s: interceptShare must open the needShareTheme handler" % THEME_ACTIVITY)
148+ if not re.search(r"themeListRow2 = rowCount\+\+;\s*\n\s*shuffleStyleRow = rowCount\+\+;", src):
149+ fail("%s: the shuffle row must follow themeListRow2" % THEME_ACTIVITY)
150+ if not re.search(r"\} else if \(id == reset_settings\) \{.*?Theme\.reloadWallpaper\(true\);\s*\n\s*\}\s*\n\s*\}\s*\n\s*"
151+ + P + r"RandomStyle\.resetToGeneratedStyle\(\);[^\n]*\n\s*\}\);", src, re.S):
152+ fail("%s: resetToGeneratedStyle must end the Reset dialog's positive handler" % THEME_ACTIVITY)
153+ if not re.search(r"listView\.setOnItemClickListener\(\(view, position, x, y\) -> \{\s*\n\s*if \(position == shuffleStyleRow\)", src):
154+ fail("%s: the shuffle click must open the list's click listener" % THEME_ACTIVITY)
155+ if not re.search(r"case TYPE_TEXT_PREFERENCE: \{\s*\n\s*TextCell cell = \(TextCell\) holder\.itemView;\s*\n\s*"
156+ r"cell\.heightDp = 48;\s*\n\s*if \(position == shuffleStyleRow\)", src):
157+ fail("%s: bindShuffleRow must open the TYPE_TEXT_PREFERENCE binding" % THEME_ACTIVITY)
158+ if not re.search(r"public int getItemViewType\(int position\) \{\s*\n\s*if \(position == shuffleStyleRow\) return TYPE_TEXT_PREFERENCE;", src):
159+ fail("%s: the shuffle row's view type must open getItemViewType" % THEME_ACTIVITY)
160+ src = read(PREVIEW_CELL)
161+ if not re.search(r"public void updateDayNightMode\(\) \{\s*\n\s*" + P + r"RandomStyleUi\.refreshCustomTile", src):
162+ fail("%s: refreshCustomTile must open updateDayNightMode" % PREVIEW_CELL)
163+
164+
165+def strip_comments(src):
166+ src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
167+ return re.sub(r"//[^\n]*", "", src)
168+
169+
170+def check_feature_code():
171+ for rel in FEATURE:
172+ code = strip_comments(read(rel))
173+ for call in FORBIDDEN_CALLS:
174+ if call in code:
175+ fail("%s calls %s" % (rel, call))
176+ for m in re.finditer(r"saveThemeAccents\(([^;]*)\);", code):
177+ args = [a.strip() for a in m.group(1).split(",")]
178+ if len(args) < 5 or args[4] != "false":
179+ fail("%s: saveThemeAccents(%s) must pass upload = false" % (rel, m.group(1)))
180+ for m in re.finditer(r"\.patternSlug\s*=\s*([^;]*);", code):
181+ if m.group(1).strip() != '""':
182+ fail("%s: generated accents get no pattern slug, found patternSlug = %s" % (rel, m.group(1).strip()))
183+ if re.search(r"LocaleController\.(getString|formatString)\([^)]*R\.string\.MultiGram", code):
184+ fail("%s reads a MultiGram string through LocaleController (use Context.getString)" % rel)
185+ code = strip_comments(read(MG + "RandomStyle.java"))
186+ if not re.search(r"new SecureRandom\(\)\.nextLong\(\)", code):
187+ fail("RandomStyle.java: the seed must come from SecureRandom")
188+ if not re.search(r'PREFS = "%s"' % PREFS_NAME, code):
189+ fail("RandomStyle.java: PREFS is not \"%s\"" % PREFS_NAME)
190+
191+
192+def check_strings():
193+ xml = read(STRINGS)
194+ names = re.findall(r'<string name="(\w+)"', xml)
195+ if len(names) != len(set(names)):
196+ fail("%s: duplicate string names" % STRINGS)
197+ for n in names:
198+ if not n.startswith("MultiGram"):
199+ fail("%s: string %s must start with MultiGram" % (STRINGS, n))
200+ used = set()
201+ for dirpath, _, files in os.walk(os.path.join(ROOT, MAIN, "java")):
202+ for f in files:
203+ if f.endswith((".java", ".kt")):
204+ with open(os.path.join(dirpath, f), encoding="utf-8", errors="replace") as fh:
205+ src = fh.read()
206+ used.update(re.findall(r"R\.string\.(MultiGram\w+)", src))
207+ if re.search(r"LocaleController\.(getString|formatString)\([^;]*R\.string\.MultiGram", src):
208+ fail("%s reads a MultiGram string through LocaleController" % f)
209+ for n in sorted(used - set(names)):
210+ fail("R.string.%s is used but not defined in %s" % (n, STRINGS))
211+ for n in sorted(set(names) - used):
212+ fail("%s: string %s is not used" % (STRINGS, n))
213+ for dirpath, _, files in os.walk(os.path.join(ROOT, MAIN, "res")):
214+ for f in files:
215+ full = os.path.join(dirpath, f)
216+ if f.endswith(".xml") and os.path.relpath(full, ROOT) != STRINGS and dirpath.endswith("/values"):
217+ with open(full, encoding="utf-8", errors="replace") as fh:
218+ other = fh.read()
219+ for n in names:
220+ if re.search(r'<string name="%s"' % n, other):
221+ fail("string %s is also defined in %s" % (n, os.path.relpath(full, ROOT)))
222+
223+
224+def check_asset_and_backup():
225+ if not os.path.isfile(os.path.join(ROOT, ASSET)):
226+ fail("missing asset %s" % ASSET)
227+ kt = read(SETTINGS_BACKUP)
228+ m = re.search(r"ALLOWED_PREFS\s*=\s*listOf\((.*?)\)", kt, re.S)
229+ if not m:
230+ fail("%s: ALLOWED_PREFS not found" % SETTINGS_BACKUP)
231+ elif '"%s"' % PREFS_NAME in m.group(1):
232+ fail("%s exports \"%s\" (the seed stays on the install)" % (SETTINGS_BACKUP, PREFS_NAME))
233+ if PREFS_NAME in read(BACKUP_AGENT):
234+ fail("%s backs up \"%s\"" % (BACKUP_AGENT, PREFS_NAME))
235+ manifest = read(MAIN + "AndroidManifest.xml")
236+ if 'android:backupAgent=".BackupAgent"' not in manifest or "android:fullBackupOnly=\"true\"" in manifest:
237+ fail("AndroidManifest.xml: the key/value BackupAgent no longer owns backup; re-check the backup policy")
238+
239+
240+def check_diff(base):
241+ """Every non-blank line added to the hooked upstream files since base carries the marker."""
242+ for rel in sorted({h[0] for h in HOOKS}):
243+ out = subprocess.run(["git", "-C", ROOT, "diff", "-U0", base, "--", rel],
244+ capture_output=True, text=True)
245+ if out.returncode != 0:
246+ fail("git diff %s -- %s failed: %s" % (base, rel, out.stderr.strip()))
247+ continue
248+ for line in out.stdout.split("\n"):
249+ if line.startswith("+") and not line.startswith("+++") and line[1:].strip() and "MultiGram:" not in line:
250+ fail("%s: added line without a \"MultiGram:\" marker: %s" % (rel, line[1:].strip()))
251+
252+
253+def main():
254+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
255+ ap.add_argument("--base", help="git revision of the unpatched Forkgram snapshot (optional diff check)")
256+ args = ap.parse_args()
257+ check_hooks()
258+ check_placement()
259+ check_feature_code()
260+ check_strings()
261+ check_asset_and_backup()
262+ if args.base:
263+ check_diff(args.base)
264+ for e in errors:
265+ print("random-style: " + e)
266+ print("random-style: %s" % ("FAILED, %d problem(s)" % len(errors) if errors else "OK"))
267+ return 1 if errors else 0
268+
269+
270+if __name__ == "__main__":
271+ sys.exit(main())