on multigram-before-13.0.4 help sign in
Multigram/multigram/tools/style_knobs_check.py
265 lines 14 kB history raw
1#!/usr/bin/env python3
2"""Check the MultiGram style knobs (multigram/style-knobs/README.md) in this source tree.
4Run from anywhere:
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
10What 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.
24Standard library only. Exit status 0 when everything matches, 1 otherwise.
25"""
26import argparse
27import os
28import re
29import subprocess
30import sys
32ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
33JAVA = "TMessagesProj/src/main/java/org/telegram"
34MG = JAVA + "/messenger/multigram/"
35KNOBS = MG + "StyleKnobs.java"
36RANDOM_STYLE = MG + "RandomStyle.java"
37RANDOM_STYLE_UI = MG + "RandomStyleUi.java"
38APP = JAVA + "/messenger/ApplicationLoader.java"
39THEME = JAVA + "/ui/ActionBar/Theme.java"
40THEME_ACTIVITY = JAVA + "/ui/ThemeActivity.java"
41ACTION_CELL = JAVA + "/ui/Cells/ChatActionCell.java"
42REACTIONS = JAVA + "/ui/Components/Reactions/ReactionsLayoutInBubble.java"
43CTA = JAVA + "/ui/Stories/recorder/ButtonWithCounterView.java"
44FAB = JAVA + "/ui/Components/FragmentFloatingButton.java"
45LIST = JAVA + "/ui/Components/RecyclerListView.java"
46UNIVERSAL_LIST = JAVA + "/ui/Components/UniversalRecyclerView.java"
47GRADIENT_CTA = JAVA + "/ui/Components/Premium/boosts/GradientButtonWithCounterView.java"
49P = r"org\.telegram\.messenger\.multigram\.StyleKnobs\."
50M = r" // MultiGram: "
52# (file, regex, expected count). Each hook is one line ending in a "MultiGram:" comment.
53HOOKS = [
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),
75UPSTREAM = sorted({h[0] for h in HOOKS})
77# Stock lines that must be gone (the hook replaced them), per file.
78REPLACED = [
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\)"),
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.
95OWNED_MAIN_KEYS = {"bubbleRadius", "useThreeLinesLayout"}
96NEVER_NAMED = ["avatarCorners", "squareAvatars", "AVATAR_CORNERS_"]
97FORBIDDEN_CALLS = ["sendRequest", "getConnectionsManager", "ConnectionsManager", "saveThemeToServer", "installTheme",
98 "uploadFile", "setFontSize", "fontSizeIsDefault"]
100# name of the Java table -> (allowed values)
101RANGES = {
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
108errors = []
111def fail(msg):
112 errors.append(msg)
115def read(rel):
116 with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
117 return f.read()
120def strip_comments(src):
121 src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
122 return re.sub(r"//[^\n]*", "", src)
125def 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
142def 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()))
156def 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()
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)
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)
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))
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)
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)
201def 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)
237def 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()))
249def 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
264if __name__ == "__main__":
265 sys.exit(main())