@@ -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+}