on main help sign in
Randomise shapes and chat list layout per install

Extend the per-install style from colours to shapes. StyleKnobs derives each knob from the install's seed through its own stream: bubble radius (8-17), two- or three-line chat list, service pill, reaction chip and button corners that follow the bubble radius, a circle or rounded-square floating button, settings card corners, and the starting phase of the wallpaper gradient. Avatars keep their shape: Forkgram's Rounded mode scales every image corner, round videos included. The two stock settings are written only on installs with a seed and only while they still hold the value StyleKnobs gave them (or their stock default), so user choices and existing installs are kept. They are also copied into SharedConfig at start, because large screens load it before the hook. Shuffle and Undo re-roll every knob and Chat Settings rebinds the rows that show them; Reset returns to the install's radius. Service pills keep their stock size and padding. Text size and behaviour are never changed. Upstream edits are one-line hooks marked "MultiGram:", checked by style_knobs_check.py. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mVy1fu8zBn38wHgJqmLNz

commit70f229293132f0232bef23fa676a7d12b306c528 authorClaude <noreply@anthropic.com> dateSun 27 Sep 2026, 04:57 (2 weeks ago) parent601bfb1
18 files changed+1003−21
TMessagesProj/src/main/java/org/telegram/messenger/ApplicationLoader.java+1 TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyle.java+5 TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyleUi.java+85 TMessagesProj/src/main/java/org/telegram/messenger/multigram/StyleKnobs.java+445 TMessagesProj/src/main/java/org/telegram/ui/ActionBar/Theme.java+1 TMessagesProj/src/main/java/org/telegram/ui/Cells/ChatActionCell.java+4−4 TMessagesProj/src/main/java/org/telegram/ui/Components/FragmentFloatingButton.java+4−3 TMessagesProj/src/main/java/org/telegram/ui/Components/Premium/boosts/GradientButtonWithCounterView.java+1 TMessagesProj/src/main/java/org/telegram/ui/Components/Reactions/ReactionsLayoutInBubble.java+2−2 TMessagesProj/src/main/java/org/telegram/ui/Components/RecyclerListView.java+2−2 TMessagesProj/src/main/java/org/telegram/ui/Components/UniversalRecyclerView.java+2−2 TMessagesProj/src/main/java/org/telegram/ui/Stories/recorder/ButtonWithCounterView.java+2−2 TMessagesProj/src/main/java/org/telegram/ui/ThemeActivity.java+4−1 multigram/random-style/README.md+8−5 multigram/style-knobs/README.md+164 multigram/tools/README.md+2 multigram/tools/check.sh+6 multigram/tools/style_knobs_check.py+265
TMessagesProj/src/main/java/org/telegram/messenger/ApplicationLoader.java+1
@@ -368,6 +368,7 @@ public class ApplicationLoader extends Application {
368368 applicationContext = getApplicationContext();
369369 }
370370 org.telegram.messenger.multigram.RandomStyle.onApplicationCreate(applicationContext); // MultiGram: per-install style seed, before anything touches Theme
371+ org.telegram.messenger.multigram.StyleKnobs.onApplicationCreate(applicationContext); // MultiGram: per-install shapes and chat list layout, before SharedConfig and Theme read them
371372
372373 NativeLoader.initNativeLibs(ApplicationLoader.applicationContext);
373374
TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyle.java+5
@@ -34,6 +34,8 @@ import java.util.zip.CRC32;
3434 * <li>{@link #shuffle}: "Shuffle my style" in Chat Settings: new seed, new style, applied at once, with
3535 * {@link #undoShuffle} for a few seconds afterwards. {@link #resetToGeneratedStyle}: "Reset to defaults" also
3636 * returns to the install's generated style (stock Chat Settings only resets text size and bubble radius).</li>
37+ * <li>{@link StyleKnobs} (shapes and chat list layout from the same seed) is updated right before Shuffle, Undo and
38+ * Reset show the style, because the theme switch reloads the wallpaper synchronously; listeners run after.</li>
3739 * </ul>
3840 *
3941 * The generated accents are local only: {@link #isGenerated} marks them, and the upload and share paths refuse them.
@@ -360,6 +362,7 @@ public final class RandomStyle {
360362 previous.shuffledSeed = seed;
361363 }
362364 undo = previous;
365+ StyleKnobs.onStyleApplying(seed); // before the theme switch, which reloads the wallpaper at once
363366 show(pair);
364367 notifyListeners(seed, REASON_SHUFFLE);
365368 return true;
@@ -401,6 +404,7 @@ public final class RandomStyle {
401404 putStyle(editor, DAY, u.day, u.dayIndex);
402405 putStyle(editor, NIGHT, u.night, u.nightIndex);
403406 putPair(editor, pair).commit();
407+ StyleKnobs.onStyleApplying(u.seed);
404408 show(pair);
405409 notifyListeners(u.seed, REASON_UNDO);
406410 return true;
@@ -430,6 +434,7 @@ public final class RandomStyle {
430434 return false;
431435 }
432436 putPair(p.edit(), pair).commit();
437+ StyleKnobs.onStyleApplying(p.getLong(KEY_SEED, 0));
433438 show(pair);
434439 notifyListeners(p.getLong(KEY_SEED, 0), REASON_RESET);
435440 return true;
TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyleUi.java+85
@@ -1,19 +1,29 @@
11package org.telegram.messenger.multigram;
22
33import android.os.SystemClock;
4+import android.view.View;
5+import android.view.ViewGroup;
46
57import org.telegram.messenger.ApplicationLoader;
68import org.telegram.messenger.FileLog;
9+import org.telegram.messenger.MessageObject;
710import org.telegram.messenger.R;
11+import org.telegram.messenger.SharedConfig;
812import org.telegram.messenger.Utilities;
913import org.telegram.ui.ActionBar.BaseFragment;
1014import org.telegram.ui.ActionBar.EmojiThemes;
1115import org.telegram.ui.ActionBar.Theme;
16+import org.telegram.ui.Cells.ChatListCell;
17+import org.telegram.ui.Cells.ChatMessageCell;
1218import org.telegram.ui.Cells.TextCell;
19+import org.telegram.ui.Cells.ThemePreviewMessagesCell;
1320import org.telegram.ui.Components.BulletinFactory;
1421import org.telegram.ui.Components.ChatThemeBottomSheet;
22+import org.telegram.ui.Components.RadioButton;
1523import org.telegram.ui.DialogsActivity;
24+import org.telegram.ui.ThemeActivity;
1625
26+import java.util.ArrayList;
1727import java.util.List;
1828
1929/**
@@ -69,15 +79,90 @@ public final class RandomStyleUi {
6979 }
7080 return;
7181 }
82+ refreshStyleKnobRows(fragment);
7283 if (fragment != null && RandomStyle.canUndoShuffle()) {
7384 BulletinFactory.of(fragment).createUndoBulletin(str(R.string.MultiGramStyleShuffled), () -> {
7485 if (!RandomStyle.undoShuffle()) {
7586 BulletinFactory.of(fragment).createErrorBulletin(str(R.string.MultiGramUndoFailed)).show();
87+ } else {
88+ refreshStyleKnobRows(fragment);
7689 }
7790 }, null).show();
7891 }
7992 }
8093
94+ /**
95+ * Shuffle and Undo may change the bubble radius and the chat list layout ({@link StyleKnobs}); Chat Settings
96+ * rebinds the rows that show them ({@link #bindStyleKnobRow}).
97+ */
98+ private static void refreshStyleKnobRows(BaseFragment fragment) {
99+ try {
100+ if (fragment instanceof ThemeActivity) {
101+ ((ThemeActivity) fragment).refreshStyleKnobRows();
102+ }
103+ } catch (Throwable e) {
104+ FileLog.e(e);
105+ }
106+ }
107+
108+ /**
109+ * ThemeActivity.ListAdapter.onBindViewHolder hook for the text size preview, the message corners slider and the
110+ * chat list picker. Their cells read the bubble radius and chat list layout only when created (the picker's
111+ * radio buttons) or measured (the slider's position, the preview's bubbles), and binding them does nothing in
112+ * stock, so after Shuffle or Undo ({@link ThemeActivity#refreshStyleKnobRows}) a visible row, or one kept
113+ * off screen, would still show the old values. This brings the cell in line with SharedConfig the way the
114+ * stock slider does: the preview messages are laid out again, the cell is measured again, and the picker's
115+ * radio buttons are re-checked. A cell never laid out was just created from the current values and is left
116+ * alone, and so is every cell while the install has no style knobs. Never throws.
117+ */
118+ public static void bindStyleKnobRow(View itemView) {
119+ try {
120+ if (itemView == null || !StyleKnobs.isActive() || itemView.getWidth() == 0) {
121+ return;
122+ }
123+ if (itemView instanceof ChatListCell) {
124+ // Its two options in order (two lines, three lines), each with one radio button; a button animates
125+ // only while attached, and does nothing when it already shows the value.
126+ List<RadioButton> buttons = new ArrayList<>();
127+ collect(itemView, RadioButton.class, buttons);
128+ if (buttons.size() == 2) {
129+ buttons.get(0).setChecked(!SharedConfig.useThreeLinesLayout, true);
130+ buttons.get(1).setChecked(SharedConfig.useThreeLinesLayout, true);
131+ }
132+ } else {
133+ List<ThemePreviewMessagesCell> previews = new ArrayList<>();
134+ collect(itemView, ThemePreviewMessagesCell.class, previews);
135+ for (ThemePreviewMessagesCell preview : previews) {
136+ ChatMessageCell[] cells = preview.getCells();
137+ for (int i = 0; cells != null && i < cells.length; i++) {
138+ MessageObject message = cells[i] == null ? null : cells[i].getMessageObject();
139+ if (message != null) {
140+ message.resetLayout();
141+ cells[i].requestLayout();
142+ }
143+ }
144+ }
145+ itemView.requestLayout(); // the corners slider takes its position from SharedConfig in onMeasure
146+ }
147+ itemView.invalidate();
148+ } catch (Throwable e) {
149+ FileLog.e(e);
150+ }
151+ }
152+
153+ /** Adds view and its descendants that are instances of type to out, depth first. */
154+ private static <T> void collect(View view, Class<T> type, List<T> out) {
155+ if (type.isInstance(view)) {
156+ out.add(type.cast(view));
157+ }
158+ if (view instanceof ViewGroup) {
159+ ViewGroup group = (ViewGroup) view;
160+ for (int i = 0; i < group.getChildCount(); i++) {
161+ collect(group.getChildAt(i), type, out);
162+ }
163+ }
164+ }
165+
81166 /**
82167 * DefaultThemesPreviewCell.updateDayNightMode hook. The custom tile of Chat Settings' theme strip is built once,
83168 * from themeconfig's lastDay/DarkCustomTheme(AccentId), but Shuffle, Undo and Reset replace the accents it points
TMessagesProj/src/main/java/org/telegram/messenger/multigram/StyleKnobs.javanew file+445
@@ -0,0 +1,445 @@
1+package org.telegram.messenger.multigram;
2+
3+import android.content.Context;
4+import android.content.SharedPreferences;
5+import android.content.res.Configuration;
6+import android.graphics.drawable.Drawable;
7+
8+import org.telegram.messenger.AndroidUtilities;
9+import org.telegram.messenger.ApplicationLoader;
10+import org.telegram.messenger.FileLog;
11+import org.telegram.messenger.SharedConfig;
12+import org.telegram.ui.ActionBar.Theme;
13+
14+/**
15+ * Per-install shapes and chat list layout ("style knobs"), driven by the install's style seed
16+ * ({@link RandomStyle#getSeed()}): bubble corner radius, two- or three-line chat list, service pill, reaction chip,
17+ * call-to-action button, floating action button and settings card corners, and the starting phase of the chat
18+ * wallpaper gradient. Avatars keep their shape (see the README). Design and limits: multigram/style-knobs/README.md.
19+ *
20+ * <ul>
21+ * <li>Every knob has its own stream, {@link RandomStyle#deriveSeed}(seed, knob name), so a knob added later
22+ * never changes the others. The values are stored ({@link #PREFS}) with the seed they came from, so an app
23+ * update that changes a range never restyles an install; only a new seed (Shuffle, Undo) re-derives them.</li>
24+ * <li>Installs without a seed (existing installs that never pressed "Shuffle my style") keep every stock value:
25+ * each hook returns the stock value it is given.</li>
26+ * <li>The two knobs that are stock settings (bubble radius, chat list layout) are written into mainconfig only
27+ * while the setting is still at its stock default or at the value this class gave it, so a value the user
28+ * picked always stays. The settings keep working as before.</li>
29+ * <li>Accessibility settings (text size) are never written.</li>
30+ * </ul>
31+ *
32+ * Every public method never throws and is cheap enough for draw calls.
33+ */
34+public final class StyleKnobs {
35+
36+ /** This feature's own preferences file: the derived values and what it last wrote into mainconfig. */
37+ public static final String PREFS = "rebrand_style_knobs";
38+ /** Version of the ranges and choices below; stored with the values it derived. */
39+ public static final int VERSION = 1;
40+
41+ // Stream names for RandomStyle.deriveSeed. Never rename one: that would re-roll the knob for every new seed.
42+ // "avatar-shape" is reserved for a future avatar knob (see the README): Forkgram's Rounded mode is not used.
43+ static final String KNOB_BUBBLE_RADIUS = "bubble-radius";
44+ static final String KNOB_CHAT_LIST_DENSITY = "chat-list-density";
45+ static final String KNOB_REACTION_CHIP = "reaction-chip-shape";
46+ static final String KNOB_CTA_RADIUS = "cta-button-radius";
47+ static final String KNOB_FAB_SHAPE = "fab-shape";
48+ static final String KNOB_SETTINGS_CARDS = "settings-cards";
49+ static final String KNOB_WALLPAPER_PHASE = "wallpaper-phase";
50+
51+ /** Message corner radius (dp): the stock slider's 0-17 range from 8 up; below 8 grouped media look broken. */
52+ static final int[] BUBBLE_RADII = {8, 10, 12, 14, 17};
53+ /** Chat list layout: three lines 2 in 5 (only on screens at least 640dp long and text below 19). */
54+ static final boolean[] THREE_LINES = {false, false, false, true, true};
55+ /** Reaction chips: a pill, or a rounded rectangle that follows the bubble radius. */
56+ static final boolean[] REACTION_PILL = {true, false};
57+ /** Call-to-action button radius = bubble radius times this percentage, clamped to 6-12dp (stock 8dp). */
58+ static final int[] CTA_SCALES = {50, 75, 100};
59+ /** Floating action button: -1 circle (stock), otherwise the corner radius (dp) of a rounded square. */
60+ static final int[] FAB_RADII = {-1, -1, -1, 14, 16, 18};
61+ /** Corner radius (dp) of settings list cards (stock 16dp, inset 12dp). */
62+ static final int[] SECTION_RADII = {12, 14, 16, 20};
63+
64+ /** Stock values of the mainconfig settings. */
65+ static final int STOCK_BUBBLE_RADIUS = 17;
66+ static final boolean STOCK_THREE_LINES = false;
67+
68+ // mainconfig keys of the stock settings (SharedConfig)
69+ static final String MAIN_BUBBLE_RADIUS = "bubbleRadius";
70+ static final String MAIN_THREE_LINES = "useThreeLinesLayout";
71+ static final String MAIN_FONT_SIZE = "fons_size";
72+
73+ // PREFS keys: the derived values ...
74+ static final String KEY_SEED = "seed";
75+ static final String KEY_VERSION = "version";
76+ static final String KEY_BUBBLE_RADIUS = "bubble_radius";
77+ static final String KEY_THREE_LINES = "three_lines";
78+ static final String KEY_REACTION_PILL = "reaction_pill";
79+ static final String KEY_CTA_SCALE = "cta_scale";
80+ static final String KEY_FAB_RADIUS = "fab_radius";
81+ static final String KEY_SECTION_RADIUS = "section_radius";
82+ static final String KEY_WALLPAPER_PHASE = "wallpaper_phase";
83+ // ... and the value last written into (or found in) mainconfig: while the setting still has it, it is ours.
84+ static final String SET_BUBBLE_RADIUS = "set_bubble_radius";
85+ static final String SET_THREE_LINES = "set_three_lines";
86+
87+ /** The install's knob values. Immutable. */
88+ static final class Values {
89+ final int bubbleRadius;
90+ final boolean threeLines;
91+ final boolean reactionPill;
92+ final int ctaScale;
93+ final int fabRadius;
94+ final int sectionRadius;
95+ final int wallpaperPhase;
96+
97+ Values(int bubbleRadius, boolean threeLines, boolean reactionPill, int ctaScale, int fabRadius, int sectionRadius,
98+ int wallpaperPhase) {
99+ this.bubbleRadius = bubbleRadius;
100+ this.threeLines = threeLines;
101+ this.reactionPill = reactionPill;
102+ this.ctaScale = ctaScale;
103+ this.fabRadius = fabRadius;
104+ this.sectionRadius = sectionRadius;
105+ this.wallpaperPhase = wallpaperPhase;
106+ }
107+ }
108+
109+ /** Null while the install has no seed: every hook then returns its stock value. */
110+ private static volatile Values current;
111+ /** Set when a Shuffle, Undo or Reset is about to apply the style: the next wallpaper load starts at its phase. */
112+ private static volatile boolean restartWallpaperPhase;
113+
114+ private StyleKnobs() {
115+ }
116+
117+ // ---------------------------------------------------------------------------------------------
118+ // Derivation (pure)
119+ // ---------------------------------------------------------------------------------------------
120+
121+ /** Uniform index in [0, n) from the knob's own stream. */
122+ static int pick(long seed, String knob, int n) {
123+ return (int) ((RandomStyle.deriveSeed(seed, knob) >>> 1) % n);
124+ }
125+
126+ /**
127+ * The knob values of a seed. screenLongDp and fontSize (mainconfig 'fons_size') are the chat list guard: three
128+ * lines only on screens at least 640dp long and with message text below 19.
129+ */
130+ static Values derive(long seed, int screenLongDp, int fontSize) {
131+ boolean threeLines = THREE_LINES[pick(seed, KNOB_CHAT_LIST_DENSITY, THREE_LINES.length)] && screenLongDp >= 640 && fontSize < 19;
132+ return new Values(
133+ BUBBLE_RADII[pick(seed, KNOB_BUBBLE_RADIUS, BUBBLE_RADII.length)],
134+ threeLines,
135+ REACTION_PILL[pick(seed, KNOB_REACTION_CHIP, REACTION_PILL.length)],
136+ CTA_SCALES[pick(seed, KNOB_CTA_RADIUS, CTA_SCALES.length)],
137+ FAB_RADII[pick(seed, KNOB_FAB_SHAPE, FAB_RADII.length)],
138+ SECTION_RADII[pick(seed, KNOB_SETTINGS_CARDS, SECTION_RADII.length)],
139+ (int) (RandomStyle.deriveSeed(seed, KNOB_WALLPAPER_PHASE) & 7));
140+ }
141+
142+ /**
143+ * The stored values when they belong to seed; a knob missing from the file (added by a later version) is
144+ * derived and stored. Otherwise (first start, or a new seed) all are derived and stored.
145+ */
146+ static Values load(SharedPreferences knobs, long seed, int screenLongDp, int fontSize) {
147+ Values d = derive(seed, screenLongDp, fontSize);
148+ boolean same = knobs.contains(KEY_SEED) && knobs.getLong(KEY_SEED, 0) == seed;
149+ if (!same) {
150+ store(knobs, seed, d);
151+ return d;
152+ }
153+ Values v = new Values(
154+ knobs.getInt(KEY_BUBBLE_RADIUS, d.bubbleRadius),
155+ knobs.getBoolean(KEY_THREE_LINES, d.threeLines),
156+ knobs.getBoolean(KEY_REACTION_PILL, d.reactionPill),
157+ knobs.getInt(KEY_CTA_SCALE, d.ctaScale),
158+ knobs.getInt(KEY_FAB_RADIUS, d.fabRadius),
159+ knobs.getInt(KEY_SECTION_RADIUS, d.sectionRadius),
160+ knobs.getInt(KEY_WALLPAPER_PHASE, d.wallpaperPhase));
161+ String[] keys = {KEY_BUBBLE_RADIUS, KEY_THREE_LINES, KEY_REACTION_PILL, KEY_CTA_SCALE, KEY_FAB_RADIUS,
162+ KEY_SECTION_RADIUS, KEY_WALLPAPER_PHASE};
163+ for (String key : keys) {
164+ if (!knobs.contains(key)) {
165+ store(knobs, seed, v);
166+ break;
167+ }
168+ }
169+ return v;
170+ }
171+
172+ private static void store(SharedPreferences knobs, long seed, Values v) {
173+ knobs.edit()
174+ .putLong(KEY_SEED, seed)
175+ .putInt(KEY_VERSION, VERSION)
176+ .putInt(KEY_BUBBLE_RADIUS, v.bubbleRadius)
177+ .putBoolean(KEY_THREE_LINES, v.threeLines)
178+ .putBoolean(KEY_REACTION_PILL, v.reactionPill)
179+ .putInt(KEY_CTA_SCALE, v.ctaScale)
180+ .putInt(KEY_FAB_RADIUS, v.fabRadius)
181+ .putInt(KEY_SECTION_RADIUS, v.sectionRadius)
182+ .putInt(KEY_WALLPAPER_PHASE, v.wallpaperPhase)
183+ .commit();
184+ }
185+
186+ // ---------------------------------------------------------------------------------------------
187+ // Stock settings: written only while they are ours
188+ // ---------------------------------------------------------------------------------------------
189+
190+ /** The setting still has the value this class last gave it or, before that, its stock default. */
191+ static boolean owned(SharedPreferences knobs, String record, int value, int stock) {
192+ return knobs.contains(record) ? knobs.getInt(record, stock) == value : value == stock;
193+ }
194+
195+ static boolean owned(SharedPreferences knobs, String record, boolean value, boolean stock) {
196+ return knobs.contains(record) ? knobs.getBoolean(record, stock) == value : value == stock;
197+ }
198+
199+ /** Writes the stored values into mainconfig where the setting is still ours (start of the app). */
200+ static void applyAtStart(SharedPreferences knobs, SharedPreferences main, Values v) {
201+ SharedPreferences.Editor m = main.edit();
202+ SharedPreferences.Editor k = knobs.edit();
203+ boolean mainChanged = false;
204+ boolean knobsChanged = false;
205+ int radius = main.getInt(MAIN_BUBBLE_RADIUS, STOCK_BUBBLE_RADIUS);
206+ if (owned(knobs, SET_BUBBLE_RADIUS, radius, STOCK_BUBBLE_RADIUS)) {
207+ if (radius != v.bubbleRadius) {
208+ m.putInt(MAIN_BUBBLE_RADIUS, v.bubbleRadius);
209+ mainChanged = true;
210+ }
211+ if (!knobs.contains(SET_BUBBLE_RADIUS) || knobs.getInt(SET_BUBBLE_RADIUS, 0) != v.bubbleRadius) {
212+ k.putInt(SET_BUBBLE_RADIUS, v.bubbleRadius);
213+ knobsChanged = true;
214+ }
215+ }
216+ boolean threeLines = main.getBoolean(MAIN_THREE_LINES, STOCK_THREE_LINES);
217+ if (owned(knobs, SET_THREE_LINES, threeLines, STOCK_THREE_LINES)) {
218+ if (threeLines != v.threeLines) {
219+ m.putBoolean(MAIN_THREE_LINES, v.threeLines);
220+ mainChanged = true;
221+ }
222+ if (!knobs.contains(SET_THREE_LINES) || knobs.getBoolean(SET_THREE_LINES, false) != v.threeLines) {
223+ k.putBoolean(SET_THREE_LINES, v.threeLines);
224+ knobsChanged = true;
225+ }
226+ }
227+ // Main thread, before the first frame; only when something changed (the first start).
228+ if (mainChanged) {
229+ m.commit();
230+ }
231+ if (knobsChanged) {
232+ k.commit();
233+ }
234+ }
235+
236+ // ---------------------------------------------------------------------------------------------
237+ // Lifecycle
238+ // ---------------------------------------------------------------------------------------------
239+
240+ /**
241+ * ApplicationLoader.onCreate, right after {@link RandomStyle#onApplicationCreate}: loads (or, on a fresh install,
242+ * derives) the values and writes the stock settings that are still ours into mainconfig, then into SharedConfig's
243+ * in-memory copies. That second step matters on large screens (sw600dp: tablets, unfolded foldables): there
244+ * AndroidUtilities' static initialiser, which runs earlier in onCreate, calls isTablet(), which reads
245+ * SharedConfig.forceDisableTabletMode, so SharedConfig has already loaded mainconfig and its later loadConfig()
246+ * does nothing. On phones touching SharedConfig loads it here, from the values just committed; tablets load it
247+ * even earlier, so nothing in it depends on loading later. Before the first frame on both. Writes through
248+ * Context.getSharedPreferences, so no account singleton is created.
249+ */
250+ public static void onApplicationCreate(Context context) {
251+ try {
252+ if (!RandomStyle.hasSeed()) {
253+ return; // existing install that never shuffled: everything stays stock
254+ }
255+ SharedPreferences knobs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
256+ SharedPreferences main = context.getSharedPreferences("mainconfig", Context.MODE_PRIVATE);
257+ Values v = load(knobs, RandomStyle.getSeed(), screenLongSideDp(context), main.getInt(MAIN_FONT_SIZE, 16));
258+ applyAtStart(knobs, main, v);
259+ current = v;
260+ SharedConfig.bubbleRadius = main.getInt(MAIN_BUBBLE_RADIUS, STOCK_BUBBLE_RADIUS);
261+ SharedConfig.useThreeLinesLayout = main.getBoolean(MAIN_THREE_LINES, STOCK_THREE_LINES);
262+ } catch (Throwable e) {
263+ FileLog.e(e);
264+ }
265+ }
266+
267+ /**
268+ * RandomStyle, right before Shuffle, Undo or Reset shows the style (so the theme switch that follows already
269+ * sees the new values): a new seed re-derives every knob. The bubble radius and chat list layout change at once
270+ * where they are still ours, and so do the shapes read at draw or layout time (service pills when laid out again,
271+ * reaction chips, the floating button on its theme update); button and settings card corners are read when a
272+ * screen builds them, so they follow when each screen is next opened. The next wallpaper load starts at the
273+ * style's phase. UI thread.
274+ */
275+ static void onStyleApplying(long seed) {
276+ try {
277+ Context context = ApplicationLoader.applicationContext;
278+ SharedPreferences knobs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
279+ SharedPreferences main = context.getSharedPreferences("mainconfig", Context.MODE_PRIVATE);
280+ Values v = load(knobs, seed, screenLongSideDp(context), SharedConfig.fontSize);
281+ current = v;
282+ restartWallpaperPhase = true;
283+
284+ SharedPreferences.Editor k = knobs.edit();
285+ int radius = main.getInt(MAIN_BUBBLE_RADIUS, STOCK_BUBBLE_RADIUS);
286+ if (owned(knobs, SET_BUBBLE_RADIUS, radius, STOCK_BUBBLE_RADIUS)) {
287+ if (radius != v.bubbleRadius || SharedConfig.bubbleRadius != v.bubbleRadius) {
288+ SharedConfig.bubbleRadius = v.bubbleRadius;
289+ main.edit().putInt(MAIN_BUBBLE_RADIUS, v.bubbleRadius).apply();
290+ }
291+ k.putInt(SET_BUBBLE_RADIUS, v.bubbleRadius);
292+ }
293+ boolean threeLines = main.getBoolean(MAIN_THREE_LINES, STOCK_THREE_LINES);
294+ if (owned(knobs, SET_THREE_LINES, threeLines, STOCK_THREE_LINES)) {
295+ if (threeLines != v.threeLines || SharedConfig.useThreeLinesLayout != v.threeLines) {
296+ SharedConfig.setUseThreeLinesLayout(v.threeLines); // writes mainconfig and reloads the chat list
297+ }
298+ k.putBoolean(SET_THREE_LINES, v.threeLines);
299+ }
300+ k.apply();
301+ } catch (Throwable e) {
302+ FileLog.e(e);
303+ }
304+ }
305+
306+ /** The install has style knobs (a seed): false for installs that never shuffled, whose hooks all return stock. */
307+ static boolean isActive() {
308+ return current != null;
309+ }
310+
311+ /** The long side of the screen in dp, or 0 when unknown (then the chat list stays two-line). */
312+ private static int screenLongSideDp(Context context) {
313+ try {
314+ Configuration c = context.getResources().getConfiguration();
315+ return Math.max(c.screenWidthDp, c.screenHeightDp);
316+ } catch (Throwable e) {
317+ return 0;
318+ }
319+ }
320+
321+ // ---------------------------------------------------------------------------------------------
322+ // Hooks (each returns the stock value it is given while the install has no seed)
323+ // ---------------------------------------------------------------------------------------------
324+
325+ /**
326+ * ThemeActivity "Reset to defaults": the bubble radius goes back to this install's radius instead of stock 17,
327+ * and follows the style again from then on.
328+ */
329+ public static int resetBubbleRadius(int stock) {
330+ Values v = current;
331+ if (v == null) {
332+ return stock;
333+ }
334+ try {
335+ ApplicationLoader.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
336+ .putInt(SET_BUBBLE_RADIUS, v.bubbleRadius).apply();
337+ } catch (Throwable e) {
338+ FileLog.e(e);
339+ }
340+ return v.bubbleRadius;
341+ }
342+
343+ /**
344+ * ChatActionCell: the service and date pill's outer corner (stock 11dp) and its inner corners (8dp, 6dp). The
345+ * outer corner is clamp(round(bubble radius * 0.65), 4, 11) dp of the current bubble radius, so the user's
346+ * slider drives it too; the inner ones scale with it, at least 3dp. Never above stock, so a pill never
347+ * self-intersects. The pill's size and side padding stay stock ({@link #servicePillCornerOffset}).
348+ */
349+ public static int servicePillRadius(int stockPx) {
350+ if (current == null) {
351+ return stockPx;
352+ }
353+ int cornerDp = Math.max(4, Math.min(11, Math.round(SharedConfig.bubbleRadius * 0.65f)));
354+ if (cornerDp >= 11) {
355+ return stockPx;
356+ }
357+ return Math.max(AndroidUtilities.dp(3), Math.round(stockPx * cornerDp / 11f));
358+ }
359+
360+ /**
361+ * ChatActionCell: the pill's cornerOffset (stock 3dp), the part of each corner arc on the text's side. The pill's
362+ * side padding is corner - cornerOffset (stock 11dp - 3dp = 8dp), so with a smaller corner the offset shrinks
363+ * with it (below 0 when the corner is under 8dp) and the text keeps the stock padding; the arcs keep their 2 x
364+ * corner width. Stock when the corner is stock.
365+ */
366+ public static int servicePillCornerOffset(int stockOffsetPx, int cornerPx, int stockCornerPx) {
367+ if (cornerPx == stockCornerPx) {
368+ return stockOffsetPx;
369+ }
370+ return cornerPx - (stockCornerPx - stockOffsetPx);
371+ }
372+
373+ /**
374+ * ReactionsLayoutInBubble: the reaction chip's corner radius (stock height / 2, a pill). The rounded style uses
375+ * clamp(round(bubble radius * 0.6), 6, 10) dp, never more than the pill. Saved-message tags keep their shape.
376+ */
377+ public static float reactionChipRadius(float stockPx) {
378+ Values v = current;
379+ if (v == null || v.reactionPill) {
380+ return stockPx;
381+ }
382+ int dp = Math.max(6, Math.min(10, Math.round(SharedConfig.bubbleRadius * 0.6f)));
383+ return Math.min(stockPx, AndroidUtilities.dp(dp));
384+ }
385+
386+ /**
387+ * ButtonWithCounterView: the default corner radius of call-to-action buttons (stock 8dp); explicit
388+ * setRoundRadius and setRound calls keep their values. clamp(round(bubble radius * scale), 6, 12) dp: at most
389+ * half of the usual 24dp+ button height and below the 24dp sheet corners.
390+ */
391+ public static int ctaRadiusDp(int stockDp) {
392+ Values v = current;
393+ if (v == null) {
394+ return stockDp;
395+ }
396+ return Math.max(6, Math.min(12, Math.round(SharedConfig.bubbleRadius * v.ctaScale / 100f)));
397+ }
398+
399+ /** FragmentFloatingButton: the main button's background, a circle (stock) or this install's rounded square. */
400+ public static Drawable floatingButtonBackground(int size, int defaultColor, int pressedColor) {
401+ Values v = current;
402+ if (v == null || v.fabRadius < 0) {
403+ return Theme.createSimpleSelectorCircleDrawable(size, defaultColor, pressedColor);
404+ }
405+ return Theme.createSimpleSelectorRoundRectDrawable(AndroidUtilities.dp(v.fabRadius), defaultColor, pressedColor);
406+ }
407+
408+ /**
409+ * FragmentFloatingButton: the small button above it (36dp inside a 48dp view, stock radius 18dp, a circle)
410+ * gets the same shape as the main button: its radius scaled by 36/48.
411+ */
412+ public static int floatingSubButtonRadius(int stockPx) {
413+ Values v = current;
414+ if (v == null || v.fabRadius < 0) {
415+ return stockPx;
416+ }
417+ return Math.round(AndroidUtilities.dp(v.fabRadius) * 0.75f);
418+ }
419+
420+ /** RecyclerListView.setSections(): the default corner radius of settings list cards (stock 16dp). */
421+ public static int sectionRadius(int stockPx) {
422+ Values v = current;
423+ if (v == null) {
424+ return stockPx;
425+ }
426+ return AndroidUtilities.dp(v.sectionRadius);
427+ }
428+
429+ /**
430+ * Theme.reloadWallpaper, after the phase to continue from is taken from the old wallpaper: the gradient starts
431+ * at this install's phase (stock: 0) when there is no gradient to continue (the first load after start), and
432+ * once after Shuffle, Undo or Reset. Otherwise it keeps the phase, as stock does.
433+ */
434+ public static int wallpaperPhase(int phase, boolean continuing) {
435+ Values v = current;
436+ if (v == null) {
437+ return phase;
438+ }
439+ if (restartWallpaperPhase || !continuing) {
440+ restartWallpaperPhase = false;
441+ return v.wallpaperPhase;
442+ }
443+ return phase;
444+ }
445+}
TMessagesProj/src/main/java/org/telegram/ui/ActionBar/Theme.java+1
@@ -9281,6 +9281,7 @@ public class Theme {
92819281 } else {
92829282 previousPhase = 0;
92839283 }
9284+ previousPhase = org.telegram.messenger.multigram.StyleKnobs.wallpaperPhase(previousPhase, wallpaper instanceof MotionBackgroundDrawable); // MultiGram: the gradient starts at this install's phase, not always at 0
92849285 wallpaper = null;
92859286 themedWallpaper = null;
92869287 loadWallpaper(async);
TMessagesProj/src/main/java/org/telegram/ui/Cells/ChatActionCell.java+4−4
@@ -3547,8 +3547,8 @@ public class ChatActionCell extends BaseCell implements DownloadController.FileD
35473547 backgroundRight = 0;
35483548 lineWidths.clear();
35493549 final int count = textLayout == null ? 0 : textLayout.getLineCount();
3550− final int corner = dp(11);
3551− final int cornerIn = dp(8);
3550+ final int corner = org.telegram.messenger.multigram.StyleKnobs.servicePillRadius(dp(11)); // MultiGram: pill corners follow this install's bubble radius
3551+ final int cornerIn = org.telegram.messenger.multigram.StyleKnobs.servicePillRadius(dp(8)); // MultiGram: scaled with the corner
35523552
35533553 int prevLineWidth = 0;
35543554 for (int a = 0; a < count; a++) {
@@ -3576,8 +3576,8 @@ public class ChatActionCell extends BaseCell implements DownloadController.FileD
35763576 int x = getMeasuredWidth() / 2;
35773577 int previousLineBottom = 0;
35783578
3579− final int cornerOffset = dp(3);
3580− final int cornerInSmall = dp(6);
3579+ final int cornerOffset = org.telegram.messenger.multigram.StyleKnobs.servicePillCornerOffset(dp(3), corner, dp(11)); // MultiGram: keeps the stock side padding (corner - cornerOffset) when the corner is smaller
3580+ final int cornerInSmall = org.telegram.messenger.multigram.StyleKnobs.servicePillRadius(dp(6)); // MultiGram: scaled with the corner
35813581 final int cornerRest = corner - cornerOffset;
35823582
35833583 lineHeights.clear();
TMessagesProj/src/main/java/org/telegram/ui/Components/FragmentFloatingButton.java+4−3
@@ -70,7 +70,7 @@ public class FragmentFloatingButton extends FrameLayout implements FactorAnimato
7070
7171 ScaleStateListAnimator.apply(this);
7272 if (!isSubButton) {
73− setOutlineProvider(ViewOutlineProviderImpl.BOUNDS_OVAL);
73+ setOutlineProvider(android.view.ViewOutlineProvider.BACKGROUND); // MultiGram: the shadow follows the background, a circle or this install's rounded square (stock: BOUNDS_OVAL)
7474 setTranslationZ(dpf2(0.5f));
7575 }
7676
@@ -157,13 +157,14 @@ public class FragmentFloatingButton extends FrameLayout implements FactorAnimato
157157 iBlur3Background.updateColors();
158158 invalidate();
159159
160− int rad = dp(18);
160+ int rad = org.telegram.messenger.multigram.StyleKnobs.floatingSubButtonRadius(dp(18)); // MultiGram: same shape as the main button
161+ iBlur3Background.setRadius(rad); // MultiGram: the constructor's dp(18), updated after a Shuffle
161162 int pressedColor = Theme.getColor(Theme.key_listSelector, resourcesProvider);
162163 setBackground(Theme.createInsetRoundRectDrawable(pressedColor, rad, dp(6)));
163164 } else {
164165 imageView.setColorFilter(Theme.getColor(Theme.key_chats_actionIcon, resourcesProvider), PorterDuff.Mode.SRC_IN);
165166 progressView.setProgressColor(Theme.getColor(Theme.key_chats_actionIcon, resourcesProvider));
166− setBackground(Theme.createSimpleSelectorCircleDrawable(dp(48),
167+ setBackground(org.telegram.messenger.multigram.StyleKnobs.floatingButtonBackground(dp(48), // MultiGram: a circle or this install's rounded square (stock: Theme.createSimpleSelectorCircleDrawable)
167168 Theme.getColor(Theme.key_featuredStickers_addButton, resourcesProvider),
168169 Theme.getColor(Theme.key_featuredStickers_addButtonPressed, resourcesProvider)
169170 ));
TMessagesProj/src/main/java/org/telegram/ui/Components/Premium/boosts/GradientButtonWithCounterView.java+1
@@ -26,6 +26,7 @@ public class GradientButtonWithCounterView extends ButtonWithCounterView {
2626 flickerDrawable.animationSpeedScale = 1.2f;
2727 flickerDrawable.drawFrame = false;
2828 flickerDrawable.repeatProgress = 4f;
29+ setRoundRadius(8); // MultiGram: stock 8dp corners, which the gradient and shimmer below are drawn with (StyleKnobs.ctaRadiusDp would change only the background and ripple)
2930 }
3031
3132 @Override
TMessagesProj/src/main/java/org/telegram/ui/Components/Reactions/ReactionsLayoutInBubble.java+2−2
@@ -995,7 +995,7 @@ public class ReactionsLayoutInBubble {
995995 if (!LiteMode.isEnabled(LiteMode.FLAG_ANIMATED_EMOJI_REACTIONS) || !LiteMode.isEnabled(LiteMode.FLAG_PARTICLES)) return false;
996996
997997 AndroidUtilities.rectTmp.set(x, y, x + width, y + height);
998− float rad = height / 2f;
998+ float rad = org.telegram.messenger.multigram.StyleKnobs.reactionChipRadius(height / 2f); // MultiGram: pill or this install's rounded chip
999999
10001000 particles.bounds.set(AndroidUtilities.rectTmp);
10011001 particles.bounds.inset(-dp(4), -dp(4));
@@ -1108,7 +1108,7 @@ public class ReactionsLayoutInBubble {
11081108 canvas.save();
11091109 canvas.scale(bounceScale, bounceScale, x + w / 2f, y + height / 2f);
11101110 }
1111− float rad = height / 2f;
1111+ float rad = org.telegram.messenger.multigram.StyleKnobs.reactionChipRadius(height / 2f); // MultiGram: pill or this install's rounded chip
11121112 if (getDrawServiceShaderBackground() > 0 && !drawBgOnlyIfChosen) {
11131113 Paint paint1 = Theme.getThemePaint(Theme.key_paint_chatActionBackground, resourcesProvider);
11141114 Paint paint2 = Theme.getThemePaint(Theme.key_paint_chatActionBackgroundDarken, resourcesProvider);
TMessagesProj/src/main/java/org/telegram/ui/Components/RecyclerListView.java+2−2
@@ -3311,10 +3311,10 @@ public class RecyclerListView extends RecyclerView implements IBlur3Capture {
33113311 }
33123312
33133313 public void setSections() {
3314− setSections(dp(12), dp(16), false);
3314+ setSections(dp(12), org.telegram.messenger.multigram.StyleKnobs.sectionRadius(dp(16)), false); // MultiGram: this install's card corners
33153315 }
33163316 public void setSections(boolean topPadding) {
3317− setSections(dp(12), dp(16), topPadding);
3317+ setSections(dp(12), org.telegram.messenger.multigram.StyleKnobs.sectionRadius(dp(16)), topPadding); // MultiGram: this install's card corners
33183318 }
33193319 public void setSections(int padding, float roundRadius, boolean topPadding) {
33203320 setSections(
TMessagesProj/src/main/java/org/telegram/ui/Components/UniversalRecyclerView.java+2−2
@@ -428,10 +428,10 @@ public class UniversalRecyclerView extends RecyclerListView {
428428 }
429429
430430 public void setSections() {
431− setSections(dp(12), dp(16), false);
431+ setSections(dp(12), org.telegram.messenger.multigram.StyleKnobs.sectionRadius(dp(16)), false); // MultiGram: this install's card corners, as in RecyclerListView
432432 }
433433 public void setSections(boolean topPadding) {
434− setSections(dp(12), dp(16), topPadding);
434+ setSections(dp(12), org.telegram.messenger.multigram.StyleKnobs.sectionRadius(dp(16)), topPadding); // MultiGram: this install's card corners, as in RecyclerListView
435435 }
436436 public void setSections(int padding, float roundRadius, boolean topPadding) {
437437 super.setSections(
TMessagesProj/src/main/java/org/telegram/ui/Stories/recorder/ButtonWithCounterView.java+2−2
@@ -41,7 +41,7 @@ public class ButtonWithCounterView extends FrameLayout implements Loadable {
4141
4242 private Theme.ResourcesProvider resourcesProvider;
4343
44− private int radiusDp = 8;
44+ private int radiusDp = org.telegram.messenger.multigram.StyleKnobs.ctaRadiusDp(8); // MultiGram: this install's button corners (explicit setRoundRadius/setRound still win)
4545
4646 private final Paint paint;
4747 public final AnimatedTextView.AnimatedTextDrawable text;
@@ -112,7 +112,7 @@ public class ButtonWithCounterView extends FrameLayout implements Loadable {
112112 addView(rippleView, LayoutHelper.createFrame(LayoutHelper.MATCH_PARENT, LayoutHelper.MATCH_PARENT));
113113
114114 if (filled) {
115− setBackground(Theme.createRoundRectDrawable(dp(8), backgroundColor = Theme.getColor(Theme.key_featuredStickers_addButton, resourcesProvider)));
115+ setBackground(Theme.createRoundRectDrawable(dp(radiusDp), backgroundColor = Theme.getColor(Theme.key_featuredStickers_addButton, resourcesProvider))); // MultiGram: radiusDp (stock: dp(8), the same default)
116116 }
117117
118118 paint = new Paint(Paint.ANTI_ALIAS_FLAG);
TMessagesProj/src/main/java/org/telegram/ui/ThemeActivity.java+4−1
@@ -461,6 +461,8 @@ public class ThemeActivity extends BaseFragment implements NotificationCenter.No
461461 updateRows(true);
462462 }
463463
464+ public void refreshStyleKnobRows() { if (listAdapter != null) { for (int row : new int[] {textSizeRow, bubbleRadiusRow, chatListRow}) { if (row >= 0) listAdapter.notifyItemChanged(row); } } } // MultiGram: Shuffle and Undo change the bubble radius and chat list layout these rows show (StyleKnobs); the rebind updates their cells (onBindViewHolder)
465+
464466 private boolean setBubbleRadius(int size, boolean layout) {
465467 if (size != SharedConfig.bubbleRadius) {
466468 SharedConfig.bubbleRadius = size;
@@ -1012,7 +1014,7 @@ public class ThemeActivity extends BaseFragment implements NotificationCenter.No
10121014 if (setFontSize(AndroidUtilities.isTablet() ? 18 : 16)) {
10131015 changed = true;
10141016 }
1015− if (setBubbleRadius(17, true)) {
1017+ if (setBubbleRadius(org.telegram.messenger.multigram.StyleKnobs.resetBubbleRadius(17), true)) { // MultiGram: this install's radius, not always 17
10161018 changed = true;
10171019 }
10181020 if (changed) {
@@ -2474,6 +2476,7 @@ public class ThemeActivity extends BaseFragment implements NotificationCenter.No
24742476 @Override
24752477 public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
24762478 switch (holder.getItemViewType()) {
2479+ case TYPE_TEXT_SIZE: case TYPE_BUBBLE_RADIUS: case TYPE_CHAT_LIST: org.telegram.messenger.multigram.RandomStyleUi.bindStyleKnobRow(holder.itemView); break; // MultiGram: these cells read the bubble radius and chat list layout only when created or measured; Shuffle and Undo change them (StyleKnobs)
24772480 case TYPE_TEXT_SETTING: {
24782481 TextSettingsCell cell = (TextSettingsCell) holder.itemView;
24792482 if (position == nightThemeRow) {
multigram/random-style/README.md+8−5
@@ -159,7 +159,9 @@ accents with upload on.
159159 reopening the screen.
160160- **Auto-night.** Stage B and Shuffle never change `selectedAutoNightType`. On a fresh install it stays at the
161161 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.
162+- **Bubble radius and chat list layout** are Forkgram settings this feature does not touch. The per-install
163+ shape knobs in `StyleKnobs` pick them from the same seed (see `multigram/style-knobs/README.md`). The avatar
164+ shape is left alone by both.
163165- User accents made from scratch, and stock presets, keep uploading and sharing as in stock.
164166
165167## Hooks in upstream files
@@ -203,13 +205,14 @@ derive its value with `deriveSeed(getSeed(), "<knob name>")`, and store the resu
203205Existing installs have no seed until they shuffle, so a knob should keep its stock value for them. On
204206`REASON_SHUFFLE` and `REASON_UNDO` a knob re-derives from the seed it is given (Undo restores the previous one). On
205207`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.
208+radius; the bubble radius that reset picks comes from `StyleKnobs.resetBubbleRadius`.
209+
210+A knob whose value must be in place before the theme switch (the wallpaper is reloaded synchronously inside it)
211+cannot use a listener. `StyleKnobs.onStyleApplying(seed)` is called directly by Shuffle, Undo and Reset just
212+before they show the new pair; see `multigram/style-knobs/README.md`.
207213
208214Not implemented here, left to the knobs work (research: `random-style/style-knobs.json`):
209215
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`.
213216- **style-consumers-outside-theme** (notification accent). `getStyle(false).accent` is readable from the push
214217 path without loading Theme (StyleTable never touches it); remap it to the knob's OKLCH band there.
215218
multigram/style-knobs/README.mdnew file+164
@@ -0,0 +1,164 @@
1+# Style knobs: per-install shapes and chat list layout
2+
3+The per-install random style (`multigram/random-style/README.md`) gives every install its own colours. The style
4+knobs use the same seed for the shapes and the chat list: how round the message bubbles, service pills, reaction
5+chips, buttons and settings cards are, whether the floating action button is a circle or a rounded square, the
6+two- or three-line chat list, and where the wallpaper gradient starts. Every value is a shape or layout that the
7+app already supports; nothing changes behaviour, and accessibility settings (text size) are never touched.
8+Avatars keep their shape (see "Considered and not done").
9+
10+Code: `org.telegram.messenger.multigram.StyleKnobs`. Check: `multigram/tools/style_knobs_check.py`, which
11+`multigram/tools/check.sh` runs. Research: `random-style/style-knobs.json` in the project files (safe ranges,
12+guardrails).
13+
14+## The knobs
15+
16+Each knob has its own stream, `RandomStyle.deriveSeed(seed, "<knob>")`, so adding a knob never changes the others.
17+An index into the knob's table is `(stream >>> 1) % length`, so every table entry is equally likely.
18+
19+| Knob (stream name) | Values | Stock | Setting the user already has | Hook |
20+|---|---|---|---|---|
21+| `bubble-radius` | 8, 10, 12, 14 or 17 dp | 17 | Chat Settings, Message corners slider (0-17) | mainconfig `bubbleRadius`, written at start; `ThemeActivity` Reset |
22+| `chat-list-density` | two lines (3 in 5) or three lines (2 in 5); two lines on screens under 640 dp or with text size 19+ | two lines | Chat Settings, Chat list view | mainconfig `useThreeLinesLayout`, written at start |
23+| service pill (no own stream) | corner `clamp(round(bubble radius x 0.65), 4, 11)` dp; inner corners 8 and 6 dp scaled with it, at least 3 dp; the pill's size and its 8 dp side padding stay stock | 11 / 8 / 6 dp | follows the bubble radius slider | `ChatActionCell`, 4 lines |
24+| `reaction-chip-shape` | pill (1 in 2), or a rounded rectangle of `clamp(round(bubble radius x 0.6), 6, 10)` dp, never above the pill | pill | follows the slider | `ReactionsLayoutInBubble`, 2 lines |
25+| `cta-button-radius` | `clamp(round(bubble radius x 50, 75 or 100 %), 6, 12)` dp | 8 dp | follows the slider; `setRound()` and `setRoundRadius()` callers keep their values | `ButtonWithCounterView`, 2 lines; `GradientButtonWithCounterView` keeps stock 8 dp, 1 line |
26+| `fab-shape` | circle (1 in 2) or a rounded square of 14, 16 or 18 dp; the small button above it takes the same shape (radius x 36/48) | circle | none | `FragmentFloatingButton`, 4 lines |
27+| `settings-cards` | card corner 12, 14, 16 or 20 dp (inset stays 12 dp) | 16 dp | none | the default `setSections()` overloads of `RecyclerListView` and `UniversalRecyclerView`, 2 lines each |
28+| `wallpaper-phase` | starting phase 0-7 of the chat wallpaper gradient (`deriveSeed & 7`) | 0 | none | `Theme.reloadWallpaper`, 1 line |
29+
30+Why these ranges (from the research's guardrails):
31+
32+- **Bubble radius** stays at 8 or more: below that, grouped media corners look broken. 17 is the slider's
33+ maximum, so Chat Settings can always show the value.
34+- **Service pills, reaction chips and buttons follow the bubble radius**, so a square-ish style is square-ish
35+ everywhere, and a radius the user sets on the slider drives them through the same formulas. The pill corner
36+ never exceeds stock 11 dp, so a one-line pill never self-intersects; the inner corners never exceed the outer
37+ one. `ChatActionCell` derives the pill's side padding from the corner (`corner - cornerOffset`, stock
38+ 11 - 3 = 8 dp), so the hook also moves `cornerOffset` with the corner: the pill keeps its stock size and padding
39+ and only its corners change. Reaction chips stay at 6 dp or more next to the emoji, and saved-message tags keep their notched shape.
40+ Button radii stay at most 12 dp: half of the smallest common button height and below the 24 dp corners of
41+ bottom sheets. The boost sheet's gradient button draws its gradient and shimmer at a fixed 8 dp, so it keeps
42+ stock 8 dp. Colours are not changed.
43+- **Chat list**: only the two stock layouts, never row heights or text sizes.
44+- **Floating action button**: shape only; the size, colour keys and the 48 dp touch target stay stock. Its shadow
45+ follows the background (`ViewOutlineProvider.BACKGROUND`, which for the stock circle is the same oval as
46+ before), so shadow and ripple always match the shape.
47+- **Settings cards**: corner radius only; the inset and shadows stay stock (shadows are a debug-menu setting and
48+ depend on the palette's card contrast). `UniversalRecyclerView` (the main Settings screen, Forkgram settings,
49+ Business, Stars and others) overrides the default overloads with its own 16 dp, so it has the same hook.
50+
51+## Lifecycle
52+
53+| When | What happens |
54+|---|---|
55+| Every start, `ApplicationLoader.onCreate` right after `RandomStyle.onApplicationCreate` (before Theme and the first frame) | Installs without a seed stop here: every hook returns its stock value. Otherwise the stored values are loaded (a fresh install derives and stores them), and the two settings are written into mainconfig where they are still ours (below), then copied into `SharedConfig`'s fields. The copy matters on large screens (sw600dp: tablets, unfolded foldables): there `AndroidUtilities`' static initialiser, which runs earlier in `onCreate`, calls `isTablet()`, which reads `SharedConfig.forceDisableTabletMode`, so `SharedConfig` has already loaded mainconfig and its later `loadConfig()` does nothing. On phones the copy loads `SharedConfig` at this point, from the values just written. Normal starts write nothing. |
56+| Shuffle, Undo | `RandomStyle` calls `StyleKnobs.onStyleApplying(seed)` right before it shows the style, because the theme switch that follows applies synchronously and reloads the wallpaper. A new seed re-derives every knob. The bubble radius and chat list layout change at once where they are ours, and so do the shapes read when drawing or laying out: message bubbles, reaction chips, service pills as they are laid out again, and the floating button (it redraws its background on the theme update). The next wallpaper load starts at the new phase. Chat Settings rebinds its text size preview, radius slider and chat list picker (`ThemeActivity.refreshStyleKnobRows`), and a bind hook updates those cells, visible or kept off screen (`RandomStyleUi.bindStyleKnobRow`). The theme switch does not rebuild screens, so button and settings card corners, which a screen reads when it builds them, change when each screen is next opened. |
57+| Reset to defaults (Chat Settings) | The bubble radius goes to the install's radius instead of stock 17 (`StyleKnobs.resetBubbleRadius`) and follows the style again. When the colours are reset too, the wallpaper restarts at the install's phase. Text size is reset by stock code as before. |
58+
59+**Ours.** A stock setting is written only while it still has the value StyleKnobs last gave it (recorded in its
60+own file) or, before StyleKnobs ever set it, its stock default. So:
61+
62+- a fresh install gets its values before the first frame, on phones and large screens alike;
63+- a value the user picks (slider, chat list picker) is kept by Shuffle and Undo; if they later pick the value
64+ StyleKnobs gave, it follows the style again;
65+- an existing install that presses Shuffle gets new values for the settings still at their stock defaults and
66+ keeps the others;
67+- a settings import (Forkgram's export carries mainconfig) brings values StyleKnobs did not record, so they are
68+ kept as the user's.
69+
70+**Stable across updates.** The values are stored with the seed they came from, so an app update that changes a
71+table never restyles an install; only a new seed (Shuffle, Undo) re-derives them. A knob added by a later version
72+is missing from the file and is derived from its own stream once.
73+
74+## Preferences
75+
76+`rebrand_style_knobs` (this feature's own file). It holds values derived from the seed, not the seed's secrets;
77+like `rebrand_style`, it is in neither Android backup nor Forkgram's settings export (both are allow-lists).
78+
79+| Key | Type | Meaning |
80+|---|---|---|
81+| `seed` | long | the seed the values below came from |
82+| `version` | int | `StyleKnobs.VERSION` that derived them |
83+| `bubble_radius`, `three_lines` | int, boolean | the values for the two stock settings |
84+| `reaction_pill`, `cta_scale`, `fab_radius`, `section_radius`, `wallpaper_phase` | boolean, int (%), int (dp, -1 circle), int (dp), int | the drawn knobs |
85+| `set_bubble_radius`, `set_three_lines` | as above | what StyleKnobs last wrote into (or found in) mainconfig: "ours" while the setting still has it |
86+
87+mainconfig: `bubbleRadius`, `useThreeLinesLayout` (written only while ours). `fons_size` is only read (the chat
88+list guard). `avatarCorners` and `squareAvatars` are never read or written.
89+
90+## Hooks in upstream files
91+
92+Every hook is one line marked `// MultiGram:`; a changed stock line passes the stock value it had, so the hook
93+returns stock while the install has no seed (the bind hook and the gradient button's line do nothing then).
94+
95+| File | Hook |
96+|---|---|
97+| `ApplicationLoader.java` `onCreate` | `StyleKnobs.onApplicationCreate(applicationContext)` right after RandomStyle's Stage A |
98+| `ActionBar/Theme.java` `reloadWallpaper` | `previousPhase = StyleKnobs.wallpaperPhase(previousPhase, wallpaper instanceof MotionBackgroundDrawable)` after the stock phase |
99+| `ThemeActivity.java` | Reset: `setBubbleRadius(StyleKnobs.resetBubbleRadius(17), true)`; one-line `refreshStyleKnobRows()` (`notifyItemChanged` on the text size, radius and chat list rows); in `ListAdapter.onBindViewHolder` one `case` line for those three view types that calls `RandomStyleUi.bindStyleKnobRow(holder.itemView)`. Chat Settings turns change animations off, so a changed visible row is rebound in place; one kept off screen is rebound when it is reused |
100+| `Cells/ChatActionCell.java` | `corner`, `cornerIn`, `cornerInSmall` = `StyleKnobs.servicePillRadius(dp(11 / 8 / 6))`; `cornerOffset = StyleKnobs.servicePillCornerOffset(dp(3), corner, dp(11))` |
101+| `Components/Reactions/ReactionsLayoutInBubble.java` | both `float rad = StyleKnobs.reactionChipRadius(height / 2f)` (chip and particle clip) |
102+| `Stories/recorder/ButtonWithCounterView.java` | `radiusDp = StyleKnobs.ctaRadiusDp(8)`; the constructor's background uses `dp(radiusDp)` instead of `dp(8)` |
103+| `Components/Premium/boosts/GradientButtonWithCounterView.java` | `setRoundRadius(8)` at the end of the constructor: its gradient and shimmer are drawn at a fixed 8 dp |
104+| `Components/FragmentFloatingButton.java` | outline `ViewOutlineProvider.BACKGROUND`; `StyleKnobs.floatingButtonBackground(dp(48), ...)`; sub-button `rad = StyleKnobs.floatingSubButtonRadius(dp(18))` and `iBlur3Background.setRadius(rad)` |
105+| `Components/RecyclerListView.java`, `Components/UniversalRecyclerView.java` | both default `setSections` overloads in each: `StyleKnobs.sectionRadius(dp(16))` |
106+
107+MultiGram files: `RandomStyle` calls `StyleKnobs.onStyleApplying` in `shuffle`, `undoShuffle` and
108+`resetToGeneratedStyle`; `RandomStyleUi` calls `ThemeActivity.refreshStyleKnobRows` after Shuffle and Undo, and
109+`RandomStyleUi.bindStyleKnobRow` re-checks the chat list picker's radio buttons, lays the preview messages out
110+again and re-measures the row (the radius slider takes its position in `onMeasure`). It leaves a cell that was
111+never laid out alone: that cell was just created from the current values.
112+`style_knobs_check.py` checks every hook, its placement and marker, the settings StyleKnobs may write, and the
113+tables' ranges; `--base forkgram` also checks every added line of the hooked files.
114+
115+## Considered and not done
116+
117+| Knob | Why not |
118+|---|---|
119+| `avatar-shape` (Forkgram's Round, Rounded and Square) | The research's safe range is the circle until a forum-scaling hook ships (High effort). Forkgram's Rounded mode is not limited to avatars: `ImageReceiver.setRoundRadius` scales every image corner by 0.64, so round video messages show as rounded squares with a circular progress ring and snap back to a circle while playing, and photo corners in bubbles shrink. It also leaves user avatars in the chat header at 0.32 of the side against 0.24 for forum avatars, under the 0.12 gap the research asks for. Square erases the forum and community shape cue. The stream name stays reserved for a proper avatar knob. |
120+| FAB colour (`chats_actionBackground`) | A colour change needs the contrast checks of the colour work (the stock default of that key fails 3:1 against the white icon); shape only here. |
121+| `chat-list-badges` (counter badge radius) | Four draw sites in DialogCell plus the forum/folder `BubbleCounterPath`, FilterTabsView and GlassTabView counters would all need hooks for the badges to match; not a small hook. |
122+| Settings card shadows, inset | Shadows are a debug-menu setting and depend on card vs page contrast; the inset changes list layout. |
123+| `bubble-tail`, `overlay-roundness`, `folder-tabs-search`, `glass-chrome-look`, `settings-icon-tiles`, `header-style`, `controls` | Medium or High effort in the research: path changes, 9-patch replacements or several widget classes. |
124+| `story-ring`, `list-details`, `composer-panel` | Colour knobs: they belong with the palette work and its readability checks. |
125+| Text size, row heights | Accessibility and layout, never randomised. |
126+
127+## Device test checklist
128+
129+The logic was exercised on the JVM with stubs (fresh, existing, user override, Shuffle, Undo, Reset, restart,
130+settings import, stored values across a table change); CI only compiles.
131+
132+1. Fresh install, on a phone and on a tablet or unfolded foldable: the bubble corners and chat list layout match
133+ `rebrand_style_knobs` from the first frame; Chat Settings shows the same radius and chat list choice.
134+2. Service messages and date pills with a small radius (8 or 10): one-line and multi-line pills (for example a
135+ long pinned-message notice) have clean corners and the same size and side padding as stock, and the
136+ floating date matches.
137+3. Reactions under a message, in a channel post and on media with the rounded style; selecting one (particles
138+ stay inside the chip); saved-message tags keep their shape.
139+4. Buttons in sheets (for example gift, boost or premium sheets) and screens using `ButtonWithCounterView`:
140+ corners, pressed ripple and the loading shimmer match; `setRound()` buttons stay pills; the boost reassign
141+ sheet's gradient button keeps 8 dp corners.
142+5. Chat list FAB and the stories button above it, rounded style: shadow, pressed ripple, the show/hide
143+ animation and the progress state; other screens with a floating button (contacts, calls, new group).
144+6. Settings screens with cards (Settings, Forkgram settings, Privacy, Data and storage, Chat Settings): corners
145+ and row selectors match from screen to screen.
146+7. Wallpaper: restart twice; the generated gradient starts at the same phase each time, sending messages still
147+ rotates it, and Shuffle restarts it at the new phase.
148+8. Shuffle and Undo in Chat Settings: the preview bubbles, radius slider (thumb and number) and chat list picker
149+ update at once, also when the preview or the picker was scrolled off screen at the time; the chat list
150+ switches between two and three lines. Settings cards and sheet buttons change when their screen is next
151+ opened.
152+9. Move the slider or pick a chat list layout, then Shuffle: the choice stays. Reset to defaults: the install's
153+ radius comes back.
154+10. Existing install (update): nothing changes until Shuffle; avatars keep Forkgram's setting.
155+
156+## Known limitations
157+
158+- After Shuffle or Undo, button and settings card corners change when each screen is next opened (the theme
159+ switch does not rebuild screens).
160+- Service pills already laid out keep their corners until the cell is laid out again (a chat reopened after
161+ Shuffle is rebuilt anyway).
162+- Screens that draw their own cards or buttons with fixed radii keep them; only the shared defaults follow.
163+- Stock Chat Settings keeps offering "Reset to defaults" while the radius differs from 17 (it already does
164+ while the generated accent is selected).
multigram/tools/README.md+2
@@ -235,6 +235,8 @@ represented (288-387 entries per range and theme); bubbles 1/2/3/4 colours about
235235 against that file, so this is what ties the validation to the Java code the app runs.
236236 `random_style_check.py`, when the tree has it: the per-install random style's hooks and their
237237 placement, its privacy guards, strings and backup policy (`multigram/random-style/README.md`).
238+ `style_knobs_check.py`, when the tree has it: the style knobs' hooks and their placement, the settings
239+ they may write and their safe ranges (`multigram/style-knobs/README.md`).
2382402. `check_style_table.py`: the format rules above, every record; theme keys must be bundled base themes
239241 of this tree with the right night flag. Self-tests: the parser must recognise every form of the
240242 out-text guard (stock, `|| id > 100`, and the palette fix's exact line) with the rest of Theme.java
multigram/tools/check.sh+6
@@ -17,6 +17,12 @@ if [ -f multigram/tools/random_style_check.py ]; then
1717 python3 multigram/tools/random_style_check.py
1818fi
1919
20+if [ -f multigram/tools/style_knobs_check.py ]; then
21+ # Per-install shapes and chat list layout: hooks and their placement, owned settings only, safe ranges.
22+ echo "== style knobs: hooks, owned settings and safe ranges"
23+ python3 multigram/tools/style_knobs_check.py
24+fi
25+
2026echo "== style table: format and readability on the current theme sources"
2127python3 multigram/tools/check_style_table.py
2228
multigram/tools/style_knobs_check.pynew file+265
@@ -0,0 +1,265 @@
1+#!/usr/bin/env python3
2+"""Check the MultiGram style knobs (multigram/style-knobs/README.md) in this source tree.
3+
4+Run from anywhere:
5+
6+ python3 multigram/tools/style_knobs_check.py
7+ python3 multigram/tools/style_knobs_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 an upstream file is present exactly once, is one line marked "MultiGram:", and passes the
12+ stock value the line had before (so the hook returns stock while the install has no seed). Every line of
13+ those files that names StyleKnobs carries the marker.
14+ * Placement: the start hook directly follows RandomStyle's in ApplicationLoader.onCreate (before the native
15+ libraries load) and then copies what it wrote into SharedConfig's in-memory fields (large screens load
16+ SharedConfig before the hook); RandomStyle calls StyleKnobs.onStyleApplying right before it shows the style
17+ in shuffle, undoShuffle and resetToGeneratedStyle; the wallpaper hook directly follows the phase
18+ Theme.reloadWallpaper takes from the old wallpaper; ThemeActivity's Reset uses the install's radius, and its
19+ bind hook covers the three rows refreshStyleKnobRows rebinds.
20+ * StyleKnobs.java: never writes accessibility settings (text size), the avatar shape, or any key other than its
21+ own file and the two stock settings it owns; makes no server calls; every knob has its own stream name; the
22+ value tables stay inside the documented safe ranges.
23+
24+Standard library only. Exit status 0 when everything matches, 1 otherwise.
25+"""
26+import argparse
27+import os
28+import re
29+import subprocess
30+import sys
31+
32+ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
33+JAVA = "TMessagesProj/src/main/java/org/telegram"
34+MG = JAVA + "/messenger/multigram/"
35+KNOBS = MG + "StyleKnobs.java"
36+RANDOM_STYLE = MG + "RandomStyle.java"
37+RANDOM_STYLE_UI = MG + "RandomStyleUi.java"
38+APP = JAVA + "/messenger/ApplicationLoader.java"
39+THEME = JAVA + "/ui/ActionBar/Theme.java"
40+THEME_ACTIVITY = JAVA + "/ui/ThemeActivity.java"
41+ACTION_CELL = JAVA + "/ui/Cells/ChatActionCell.java"
42+REACTIONS = JAVA + "/ui/Components/Reactions/ReactionsLayoutInBubble.java"
43+CTA = JAVA + "/ui/Stories/recorder/ButtonWithCounterView.java"
44+FAB = JAVA + "/ui/Components/FragmentFloatingButton.java"
45+LIST = JAVA + "/ui/Components/RecyclerListView.java"
46+UNIVERSAL_LIST = JAVA + "/ui/Components/UniversalRecyclerView.java"
47+GRADIENT_CTA = JAVA + "/ui/Components/Premium/boosts/GradientButtonWithCounterView.java"
48+
49+P = r"org\.telegram\.messenger\.multigram\.StyleKnobs\."
50+M = r" // MultiGram: "
51+
52+# (file, regex, expected count). Each hook is one line ending in a "MultiGram:" comment.
53+HOOKS = [
54+ (APP, r"^ " + P + r"onApplicationCreate\(applicationContext\);" + M, 1),
55+ (THEME, r"^ previousPhase = " + P + r"wallpaperPhase\(previousPhase, wallpaper instanceof MotionBackgroundDrawable\);" + M, 1),
56+ (THEME_ACTIVITY, r"^ if \(setBubbleRadius\(" + P + r"resetBubbleRadius\(17\), true\)\) \{" + M, 1),
57+ (THEME_ACTIVITY, r"^ public void refreshStyleKnobRows\(\) \{ .*\}" + M, 1),
58+ (THEME_ACTIVITY, r"^ case TYPE_TEXT_SIZE: case TYPE_BUBBLE_RADIUS: case TYPE_CHAT_LIST: "
59+ r"org\.telegram\.messenger\.multigram\.RandomStyleUi\.bindStyleKnobRow\(holder\.itemView\); break;" + M, 1),
60+ (ACTION_CELL, r"^ final int corner = " + P + r"servicePillRadius\(dp\(11\)\);" + M, 1),
61+ (ACTION_CELL, r"^ final int cornerIn = " + P + r"servicePillRadius\(dp\(8\)\);" + M, 1),
62+ (ACTION_CELL, r"^ final int cornerOffset = " + P + r"servicePillCornerOffset\(dp\(3\), corner, dp\(11\)\);" + M, 1),
63+ (ACTION_CELL, r"^ final int cornerInSmall = " + P + r"servicePillRadius\(dp\(6\)\);" + M, 1),
64+ (REACTIONS, r"^ float rad = " + P + r"reactionChipRadius\(height / 2f\);" + M, 2),
65+ (CTA, r"^ private int radiusDp = " + P + r"ctaRadiusDp\(8\);" + M, 1),
66+ (CTA, r"^ setBackground\(Theme\.createRoundRectDrawable\(dp\(radiusDp\), backgroundColor = Theme\.getColor\(Theme\.key_featuredStickers_addButton, resourcesProvider\)\)\);" + M, 1),
67+ (FAB, r"^ setOutlineProvider\(android\.view\.ViewOutlineProvider\.BACKGROUND\);" + M, 1),
68+ (FAB, r"^ int rad = " + P + r"floatingSubButtonRadius\(dp\(18\)\);" + M, 1),
69+ (FAB, r"^ iBlur3Background\.setRadius\(rad\);" + M, 1),
70+ (FAB, r"^ setBackground\(" + P + r"floatingButtonBackground\(dp\(48\),[^\n]*" + M, 1),
71+ (LIST, r"^ setSections\(dp\(12\), " + P + r"sectionRadius\(dp\(16\)\), (false|topPadding)\);" + M, 2),
72+ (UNIVERSAL_LIST, r"^ setSections\(dp\(12\), " + P + r"sectionRadius\(dp\(16\)\), (false|topPadding)\);" + M, 2),
73+ (GRADIENT_CTA, r"^ setRoundRadius\(8\);" + M, 1),
74+]
75+UPSTREAM = sorted({h[0] for h in HOOKS})
76+
77+# Stock lines that must be gone (the hook replaced them), per file.
78+REPLACED = [
79+ (THEME_ACTIVITY, r"setBubbleRadius\(17, true\)"),
80+ (ACTION_CELL, r"final int corner = dp\(11\);"),
81+ (ACTION_CELL, r"final int cornerIn = dp\(8\);"),
82+ (ACTION_CELL, r"final int cornerOffset = dp\(3\);"),
83+ (ACTION_CELL, r"final int cornerInSmall = dp\(6\);"),
84+ (REACTIONS, r"float rad = height / 2f;"),
85+ (CTA, r"private int radiusDp = 8;"),
86+ (CTA, r"createRoundRectDrawable\(dp\(8\), backgroundColor = "),
87+ (FAB, r"ViewOutlineProviderImpl\.BOUNDS_OVAL\)"),
88+ (FAB, r"createSimpleSelectorCircleDrawable\(dp\(48\)"),
89+ (LIST, r"setSections\(dp\(12\), dp\(16\)"),
90+ (UNIVERSAL_LIST, r"setSections\(dp\(12\), dp\(16\)"),
91+]
92+
93+# Keys StyleKnobs may write in mainconfig (its own file aside). Text size ("fons_size") is read only; the avatar
94+# shape ("avatarCorners", "squareAvatars") is never touched: Forkgram's Rounded mode scales every image corner.
95+OWNED_MAIN_KEYS = {"bubbleRadius", "useThreeLinesLayout"}
96+NEVER_NAMED = ["avatarCorners", "squareAvatars", "AVATAR_CORNERS_"]
97+FORBIDDEN_CALLS = ["sendRequest", "getConnectionsManager", "ConnectionsManager", "saveThemeToServer", "installTheme",
98+ "uploadFile", "setFontSize", "fontSizeIsDefault"]
99+
100+# name of the Java table -> (allowed values)
101+RANGES = {
102+ "BUBBLE_RADII": set(range(8, 18)), # stock slider 0-17, never below 8
103+ "CTA_SCALES": set(range(25, 101)), # result clamped to 6-12dp in code
104+ "FAB_RADII": {-1} | set(range(14, 19)), # circle or 14-18dp on a 48dp button
105+ "SECTION_RADII": set(range(8, 25)), # <= inset 12dp + 12dp
106+}
107+
108+errors = []
109+
110+
111+def fail(msg):
112+ errors.append(msg)
113+
114+
115+def read(rel):
116+ with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
117+ return f.read()
118+
119+
120+def strip_comments(src):
121+ src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
122+ return re.sub(r"//[^\n]*", "", src)
123+
124+
125+def method_body(src, signature):
126+ """Source of the method whose declaration matches signature (a regex), up to its closing brace."""
127+ m = re.search(signature, src)
128+ if not m:
129+ return None
130+ i = src.index("{", m.end() - 1)
131+ depth = 0
132+ for j in range(i, len(src)):
133+ if src[j] == "{":
134+ depth += 1
135+ elif src[j] == "}":
136+ depth -= 1
137+ if depth == 0:
138+ return src[i:j + 1]
139+ return None
140+
141+
142+def check_hooks():
143+ for rel, pat, count in HOOKS:
144+ n = len(re.findall(pat, read(rel), re.M))
145+ if n != count:
146+ fail("hook %r found %d times in %s (expected %d)" % (pat, n, rel, count))
147+ for rel, pat in REPLACED:
148+ if re.search(pat, read(rel)):
149+ fail("%s: stock line %r is back (the hook must replace it)" % (rel, pat))
150+ for rel in UPSTREAM:
151+ for line in read(rel).split("\n"):
152+ if "StyleKnobs" in line and "// MultiGram: " not in line:
153+ fail("%s: line without a \"// MultiGram: \" marker: %s" % (rel, line.strip()))
154+
155+
156+def check_placement():
157+ src = read(APP)
158+ if not re.search(r"multigram\.RandomStyle\.onApplicationCreate\(applicationContext\);[^\n]*\n\s*"
159+ r"org\.telegram\.messenger\.multigram\.StyleKnobs\.onApplicationCreate\(applicationContext\);", src):
160+ fail("%s: StyleKnobs.onApplicationCreate must directly follow RandomStyle.onApplicationCreate" % APP)
161+ start = 0
162+ for pattern, what in [(r"public void onCreate\(\) \{", "ApplicationLoader.onCreate"),
163+ (r"StyleKnobs\.onApplicationCreate\(", "the StyleKnobs start hook"),
164+ (r"NativeLoader\.initNativeLibs\(", "NativeLoader.initNativeLibs")]:
165+ m = re.compile(pattern).search(src, start)
166+ if not m:
167+ fail("%s: %s not found in order (onCreate, StyleKnobs hook, NativeLoader.initNativeLibs)" % (APP, what))
168+ break
169+ start = m.end()
170+
171+ src = read(THEME)
172+ body = method_body(src, r"public static void reloadWallpaper\(boolean async\) \{")
173+ if body is None or not re.search(r"previousPhase = 0;\s*\n\s*\}\s*\n\s*previousPhase = " + P + r"wallpaperPhase\(", body):
174+ fail("%s: the wallpaper hook must directly follow the phase reloadWallpaper takes from the old wallpaper" % THEME)
175+
176+ body = method_body(read(KNOBS), r"public static void onApplicationCreate\(Context context\) \{")
177+ if body is None or not re.search(r"applyAtStart\(knobs, main, v\);(?:.|\n)*"
178+ r"SharedConfig\.bubbleRadius = main\.getInt\(MAIN_BUBBLE_RADIUS, STOCK_BUBBLE_RADIUS\);\s*\n\s*"
179+ r"SharedConfig\.useThreeLinesLayout = main\.getBoolean\(MAIN_THREE_LINES, STOCK_THREE_LINES\);", body):
180+ fail("%s: onApplicationCreate must copy the written settings into SharedConfig after applyAtStart "
181+ "(on sw600dp screens SharedConfig is loaded before the hook)" % KNOBS)
182+
183+ src = read(RANDOM_STYLE)
184+ for sig, seed in [(r"public static boolean shuffle\(Context context\) \{", r"seed"),
185+ (r"public static boolean undoShuffle\(\) \{", r"u\.seed"),
186+ (r"public static boolean resetToGeneratedStyle\(\) \{", r"p\.getLong\(KEY_SEED, 0\)")]:
187+ body = method_body(src, sig)
188+ if body is None or not re.search(r"StyleKnobs\.onStyleApplying\(" + seed + r"\);[^\n]*\n\s*show\(pair\);", body):
189+ fail("%s: %s must call StyleKnobs.onStyleApplying(%s) right before show(pair)" % (RANDOM_STYLE, sig, seed))
190+
191+ src = read(RANDOM_STYLE_UI)
192+ if len(re.findall(r"refreshStyleKnobRows\(fragment\);", src)) != 2:
193+ fail("%s: Shuffle and Undo must both refresh Chat Settings' rows (refreshStyleKnobRows)" % RANDOM_STYLE_UI)
194+
195+ src = read(THEME_ACTIVITY)
196+ if not re.search(r"\} else if \(id == reset_settings\) \{.*?if \(setBubbleRadius\(" + P + r"resetBubbleRadius\(17\), true\)\)",
197+ src, re.S):
198+ fail("%s: Reset must set the bubble radius through StyleKnobs.resetBubbleRadius" % THEME_ACTIVITY)
199+
200+
201+def check_knobs_code():
202+ raw = read(KNOBS)
203+ code = strip_comments(raw)
204+ for call in FORBIDDEN_CALLS:
205+ if call in code:
206+ fail("%s calls or names %s" % (KNOBS, call))
207+ # Writes into mainconfig: only the owned settings, through constants.
208+ consts = dict(re.findall(r'static final String (\w+) = "([^"]*)";', code))
209+ for m in re.finditer(r"\.put(?:Int|Boolean|String|Long)\((\w+)", code):
210+ key = consts.get(m.group(1), m.group(1))
211+ if m.group(1).startswith("MAIN_") and key not in OWNED_MAIN_KEYS:
212+ fail("%s writes mainconfig key %r, which is not one of its owned settings" % (KNOBS, key))
213+ if re.search(r'put\w*\(\s*"fons_size"', code) or re.search(r"put\w*\(MAIN_FONT_SIZE", code):
214+ fail("%s writes the text size (accessibility setting)" % KNOBS)
215+ streams = re.findall(r'static final String KNOB_\w+ = "([^"]+)";', code)
216+ if len(streams) != len(set(streams)):
217+ fail("%s: two knobs share a stream name" % KNOBS)
218+ for s in streams:
219+ if len(re.findall(r'"%s"' % re.escape(s), code)) != 1:
220+ fail("%s: stream name %r must be defined once, as a KNOB_ constant" % (KNOBS, s))
221+ for name, allowed in RANGES.items():
222+ m = re.search(r"static final int\[\] %s = \{([^}]*)\};" % name, code)
223+ if not m:
224+ fail("%s: table %s not found" % (KNOBS, name))
225+ continue
226+ values = [int(v) for v in m.group(1).replace(" ", "").split(",")]
227+ bad = [v for v in values if v not in allowed]
228+ if bad:
229+ fail("%s: %s has values outside its safe range: %s" % (KNOBS, name, bad))
230+ for name in NEVER_NAMED:
231+ if name in code:
232+ fail("%s names %s: the avatar shape is not a style knob (Forkgram's modes scale every image corner)" % (KNOBS, name))
233+ if 'getSharedPreferences("mainconfig"' in code and "MessagesController" in code:
234+ fail("%s: use Context.getSharedPreferences only (no MessagesController before the first frame)" % KNOBS)
235+
236+
237+def check_diff(base):
238+ """Every non-blank line added to the hooked upstream files since base carries the marker."""
239+ for rel in UPSTREAM:
240+ out = subprocess.run(["git", "-C", ROOT, "diff", "-U0", base, "--", rel], capture_output=True, text=True)
241+ if out.returncode != 0:
242+ fail("git diff %s -- %s failed: %s" % (base, rel, out.stderr.strip()))
243+ continue
244+ for line in out.stdout.split("\n"):
245+ if line.startswith("+") and not line.startswith("+++") and line[1:].strip() and "MultiGram:" not in line:
246+ fail("%s: added line without a \"MultiGram:\" marker: %s" % (rel, line[1:].strip()))
247+
248+
249+def main():
250+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
251+ ap.add_argument("--base", help="git revision of the unpatched Forkgram snapshot (optional diff check)")
252+ args = ap.parse_args()
253+ check_hooks()
254+ check_placement()
255+ check_knobs_code()
256+ if args.base:
257+ check_diff(args.base)
258+ for e in errors:
259+ print("style-knobs: " + e)
260+ print("style-knobs: %s" % ("FAILED, %d problem(s)" % len(errors) if errors else "OK"))
261+ return 1 if errors else 0
262+
263+
264+if __name__ == "__main__":
265+ sys.exit(main())