@@ -0,0 +1,1162 @@
1+#!/usr/bin/env python3
2+# -*- coding: utf-8 -*-
3+"""
4+Build-time rebrand for MultiGram (Forkgram plus the MultiGram stack, GPLv2).
5+
6+One command gives a build its own identity:
7+
8+ - a unique applicationId -> rebrand.properties, read by rebrand.gradle
9+ - its own app name (AppName, AppNameBeta, -> resource overlay (launcher label) and the
10+ AppNameFdroid) bundled in-app strings
11+ - its own adaptive launcher icon, the -> resource overlay (vector layers + PNGs)
12+ splash-screen icon and the notification
13+ icon
14+ - a fresh signing key -> keytool; path and credentials in the properties
15+ - a matching Android account type -> patched in place in three source files
16+
17+The identity (applicationId, name, icon, account type) is deterministic per --seed; the
18+signing key and its passwords are not. Everything goes under multigram/rebrand/generated/
19+and multigram/rebrand/rebrand.properties (both git-ignored); the three account-type files
20+are edited in place with backups, and --clean restores them byte for byte.
21+
22+This is white-label rebranding of your own build of open-source software. The generated
23+names and ids come from neutral word lists. Values you pass yourself are only checked
24+against a short blocklist (Telegram's name and namespace, Forkgram's names, some well-known
25+apps' names and packages), so choosing ones that belong to you is up to you.
26+
27+Standard library only; keytool (from any JDK) is needed for the signing key.
28+"""
29+
30+import argparse
31+import colorsys
32+import hashlib
33+import json
34+import math
35+import os
36+import random
37+import re
38+import secrets
39+import shlex
40+import shutil
41+import struct
42+import subprocess
43+import sys
44+import xml.etree.ElementTree as ET
45+import zlib
46+from pathlib import Path
47+
48+HERE = Path(__file__).resolve().parent # multigram/rebrand
49+REPO = HERE.parent.parent
50+GEN = HERE / "generated"
51+GEN_RES = GEN / "res"
52+BACKUP = GEN / "backup"
53+BACKUP_INDEX = BACKUP / "index.json"
54+PROPS = HERE / "rebrand.properties"
55+DEFAULT_KEYSTORE = GEN / "rebrand.keystore"
56+
57+APP_MODULE = REPO / "TMessagesProj_App"
58+LIB_MODULE = REPO / "TMessagesProj"
59+# Resource roots whose icons and app names the overlay must beat (every source set of the
60+# two modules in settings.gradle, e.g. TMessagesProj_App/src/forkTest/res).
61+RES_ROOT_GLOBS = ["TMessagesProj/src/*/res", "TMessagesProj_App/src/*/res"]
62+# Manifests that can supply the application label and icon (checked, never edited).
63+MANIFESTS = ["TMessagesProj/src/main/AndroidManifest.xml",
64+ "TMessagesProj_App/src/main/AndroidManifest.xml"]
65+CONFIG_MANIFEST_GLOB = "TMessagesProj/config/*/AndroidManifest*.xml"
66+
67+ANDROID_NS = "{http://schemas.android.com/apk/res/android}"
68+
69+# The three app-name strings. Non-F-Droid release builds take their launcher label from
70+# config/debug/AndroidManifest_SDK23.xml (@string/AppNameBeta), F-Droid builds from
71+# config/release/AndroidManifest_SDK23.xml (@string/AppName); AppNameFdroid feeds the
72+# unused appLabel placeholder in TMessagesProj/build.gradle. All three get the new name.
73+APP_NAME_STRINGS = ("AppName", "AppNameBeta", "AppNameFdroid")
74+
75+# Icon resources the overlay replaces, and how each one is drawn.
76+# square / round : a launcher icon (adaptive XML in anydpi-v26, legacy PNG per density)
77+# fg_layer : a foreground layer PNG (108dp canvas), as used by the in-app icon picker
78+# bg_layer : a background layer PNG (108dp canvas), as used by the in-app icon picker
79+# splash : the Android 12+ splash-screen icon (a 320dp vector)
80+# notif : a status-bar notification icon (white on transparent PNG)
81+ICON_TARGETS = {
82+ ("mipmap", "ic_launcher"): "square", # application icon, share targets
83+ ("mipmap", "ic_launcher_round"): "round",
84+ ("mipmap", "icon_01_launcher"): "square", # DefaultIcon launcher alias
85+ ("mipmap", "icon_01_launcher_round"): "round",
86+ ("mipmap", "icon_01_launcher_adaptive"): "square", # AdaptiveIcon launcher alias
87+ ("mipmap", "icon_01_launcher_sa"): "square",
88+ ("mipmap", "icon_01_foreground_sa"): "fg_layer", # icon picker preview, Default/Adaptive
89+ ("drawable", "icon_01_background_sa"): "bg_layer",
90+ ("drawable", "ic_launcher_dr"): "round", # account, contacts and call icon
91+ ("drawable", "splash_fork_320"): "splash", # splash screen (values-v31 styles)
92+ ("drawable", "tg_splash_320"): "splash", # splash screen, dark mode (values-night)
93+ ("drawable", "notification"): "notif", # notification small icon
94+}
95+# Theme attribute whose drawable must be in ICON_TARGETS (checked, never edited).
96+SPLASH_ATTR = "android:windowSplashScreenAnimatedIcon"
97+
98+# --- account type ---------------------------------------------------------------------
99+# Android's AccountManager keys the contact-sync account on one string that must be the
100+# same in the authenticator XML, the sync-adapter XML and the Java code. In this tree the
101+# XML says android:accountType="${applicationId}" (AGP does not expand placeholders in
102+# resource files, so that literal text is what ships) while ContactsController.java uses
103+# "org.telegram.messenger". A rebranded build sets all three to one value derived from its
104+# own applicationId. A Java literal cannot be overlaid, so these files are patched in place.
105+STOCK_JAVA_ACCOUNT_TYPE = "org.telegram.messenger"
106+JAVA_EXPECTED_LITERALS = 5
107+CONTACTS_JAVA = LIB_MODULE / "src/main/java/org/telegram/messenger/ContactsController.java"
108+ACCOUNT_XMLS = [LIB_MODULE / "src/main/res/xml/auth.xml",
109+ LIB_MODULE / "src/main/res/xml/sync_contacts.xml"]
110+ACCOUNT_XML_ATTR = re.compile(rb'android:accountType="[^"]*"')
111+STOCK_XML_ACCOUNT_TYPE = b"${applicationId}"
112+# Written into each patched file, so a patched tree is recognisable without generated/:
113+# rebrand.gradle refuses to build it as the stock identity, and --clean refuses to call it
114+# clean. rebrand.gradle looks for the same text.
115+PATCH_MARKER = "MultiGram rebrand: patched by multigram/rebrand/generate_rebrand.py"
116+
117+# --- neutral word pools ---------------------------------------------------------------
118+# Plain nature and colour words. Words that are the names of well-known apps or brands
119+# (messengers above all) are deliberately left out, and generated names are checked
120+# against BLOCKED_NAME_PARTS as well.
121+ADJ = ["amber", "cobalt", "quiet", "north", "violet", "cedar", "ivory", "coral", "slate",
122+ "marble", "willow", "lunar", "misty", "velvet", "golden", "silver", "crimson",
123+ "azure", "maple", "rustic", "gentle", "bright", "calm", "clear", "hidden", "mellow",
124+ "olive", "sandy", "tidal", "copper", "frosty", "hazel", "indigo", "jade", "linen",
125+ "mossy", "pale", "rosy", "sunlit", "wild"]
126+NOUN = ["harbor", "meadow", "canyon", "cove", "ridge", "birch", "fern", "heron", "brook",
127+ "grove", "dune", "isle", "lagoon", "valley", "thistle", "kestrel", "orchard", "tern",
128+ "wren", "hollow", "bay", "pine", "cliff", "creek", "marsh", "aspen", "bluff",
129+ "cairn", "fjord", "heath", "inlet", "knoll", "ledge", "moor", "oak", "pond", "rill",
130+ "shoal", "spruce", "stream"]
131+TLDS = ["com", "net", "io", "app", "me", "co", "org", "dev"]
132+SEGS = ["app", "chat", "mobile", "client", "talk", "messages", "android", "im"]
133+# Refused in any name, package or account type: Telegram's name (its API terms forbid it
134+# for third-party apps), Forkgram's own names (a rebrand must not pass for the upstream
135+# build) and the names of some other well-known apps. This is a short blocklist, not a
136+# guarantee: a name or id you pass yourself must be one that belongs to you.
137+BLOCKED_NAME_PARTS = ["telegram", "forkgram", "fork client", "forkclient", "whatsapp",
138+ "signal", "viber", "threema", "wechat", "kakaotalk", "facebook",
139+ "instagram", "snapchat", "discord", "skype", "imessage"]
140+# Refused as the start of an applicationId or account type: the namespaces of the platform
141+# and of some well-known vendors and messengers.
142+BLOCKED_PACKAGE_PREFIXES = ["android.", "com.android.", "com.google.", "com.samsung.",
143+ "com.apple.", "com.microsoft.", "com.facebook.", "com.instagram.",
144+ "com.whatsapp.", "com.skype.", "com.snapchat.", "com.discord.",
145+ "com.viber.", "com.tencent.", "com.kakao.", "jp.naver.line.",
146+ "org.thoughtcrime.", "org.thunderdog.", "ch.threema.",
147+ "im.vector.", "org.telegram."]
148+PACKAGE_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$")
149+JAVA_KEYWORDS = set("""abstract assert boolean break byte case catch char class const continue
150+default do double else enum extends final finally float for goto if implements import
151+instanceof int interface long native new package private protected public return short static
152+strictfp super switch synchronized this throw throws transient try void volatile while true
153+false null""".split())
154+
155+# Plain geometric marks. Rings and crosses are left out: on some hues they look like
156+# well-known logos and emblems.
157+SHAPES = ["disc", "square", "diamond", "triangle", "pentagon", "hexagon"]
158+
159+
160+class RebrandError(Exception):
161+ pass
162+
163+
164+def rel(p):
165+ try:
166+ return Path(p).resolve().relative_to(REPO).as_posix()
167+ except ValueError:
168+ return str(p)
169+
170+
171+def sha256(b):
172+ return hashlib.sha256(b).hexdigest()
173+
174+
175+# --- identity -------------------------------------------------------------------------
176+def rng_from_seed(seed):
177+ return random.Random(int(hashlib.sha256(seed.encode("utf-8")).hexdigest(), 16))
178+
179+
180+def contrast(c1, c2):
181+ def lum(c):
182+ def ch(v):
183+ v /= 255.0
184+ return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
185+ return 0.2126 * ch(c[0]) + 0.7152 * ch(c[1]) + 0.0722 * ch(c[2])
186+ a, b = sorted((lum(c1), lum(c2)), reverse=True)
187+ return (a + 0.05) / (b + 0.05)
188+
189+
190+def hsv(h, s, v):
191+ return tuple(int(round(x * 255)) for x in colorsys.hsv_to_rgb(h % 1.0, s, v))
192+
193+
194+def gen_identity(r):
195+ """Draw every random value in a fixed order, so an override (e.g. --app-name) never
196+ shifts the others: the same seed always gives the same icon, name and id."""
197+ ident = {}
198+ # applicationId: <tld>.<adjective><noun>.<segment>, e.g. net.quietcove.chat
199+ ident["application_id"] = "%s.%s%s.%s" % (r.choice(TLDS), r.choice(ADJ), r.choice(NOUN),
200+ r.choice(SEGS))
201+ # app name: short enough for a launcher label
202+ for _ in range(50):
203+ style = r.random()
204+ adj, noun = r.choice(ADJ).capitalize(), r.choice(NOUN).capitalize()
205+ if style < 0.6:
206+ name = "%s %s" % (adj, noun)
207+ elif style < 0.8:
208+ name = "%s Chat" % noun
209+ else:
210+ name = "%s%s" % (adj, noun.lower())
211+ if len(name) <= 13:
212+ break
213+ ident["app_name"] = name
214+ # icon colours: a saturated background and a light mark with at least 3:1 contrast
215+ hue = r.random()
216+ sat = r.uniform(0.50, 0.85)
217+ val = r.uniform(0.50, 0.85)
218+ light_mark = r.random() < 0.7
219+ shape = r.choice(SHAPES)
220+ fg = (255, 255, 255) if light_mark else hsv(hue + 0.5, 0.18, 0.98)
221+ bg = hsv(hue, sat, val)
222+ while contrast(bg, fg) < 3.0 and val > 0.2:
223+ val -= 0.03
224+ bg = hsv(hue, sat, val)
225+ ident["bg"], ident["fg"], ident["shape"] = bg, fg, shape
226+ return ident
227+
228+
229+def hexc(rgb):
230+ return "#%02X%02X%02X" % rgb
231+
232+
233+def check_not_blocked(what, value):
234+ low = value.lower()
235+ for part in BLOCKED_NAME_PARTS:
236+ if part in low:
237+ raise RebrandError("%s %r contains %r: pick a name of your own, never the name "
238+ "of Telegram or of another app" % (what, value, part))
239+
240+
241+def check_package(what, value):
242+ if not PACKAGE_RE.match(value):
243+ raise RebrandError("%s %r is not a valid package name (a.b[.c...], each part "
244+ "starting with a letter, then letters, digits or _)" % (what, value))
245+ if any(p in JAVA_KEYWORDS for p in value.split(".")):
246+ raise RebrandError("%s %r uses a Java keyword as a segment" % (what, value))
247+ for prefix in BLOCKED_PACKAGE_PREFIXES:
248+ if (value + ".").startswith(prefix):
249+ raise RebrandError("%s %r is in the %s namespace, which belongs to someone else: "
250+ "use a domain of your own" % (what, value, prefix[:-1]))
251+ check_not_blocked(what, value)
252+
253+
254+def check_app_name(value):
255+ if not value.strip() or len(value) > 30 or any(ord(c) < 32 for c in value):
256+ raise RebrandError("app name must be 1 to 30 printable characters")
257+ check_not_blocked("app name", value)
258+
259+
260+# --- escaping -------------------------------------------------------------------------
261+def android_string(s):
262+ """Escape text for a <string> resource."""
263+ s = s.replace("\\", "\\\\")
264+ s = s.replace("&", "&").replace("<", "<").replace(">", ">")
265+ s = s.replace("'", "\\'").replace('"', '\\"')
266+ if s[:1] in ("@", "?"):
267+ s = "\\" + s
268+ return s
269+
270+
271+def prop_escape(s, key=False):
272+ """Escape a value for java.util.Properties.load (ISO-8859-1 with \\u escapes)."""
273+ out = []
274+ for i, c in enumerate(s):
275+ o = ord(c)
276+ if c == "\\":
277+ out.append("\\\\")
278+ elif c in "=:#!" or (c == " " and (key or i == 0)):
279+ out.append("\\" + c)
280+ elif c == "\t":
281+ out.append("\\t")
282+ elif o < 0x20 or o > 0x7e:
283+ if o > 0xffff:
284+ o -= 0x10000
285+ out.append("\\u%04x\\u%04x" % (0xd800 + (o >> 10), 0xdc00 + (o & 0x3ff)))
286+ else:
287+ out.append("\\u%04x" % o)
288+ else:
289+ out.append(c)
290+ return "".join(out)
291+
292+
293+def prop_unescape(s):
294+ def one(m):
295+ e = m.group(1)
296+ if e[0] == "u":
297+ return chr(int(e[1:], 16))
298+ return {"t": "\t", "n": "\n", "r": "\r", "f": "\f"}.get(e, e)
299+ # \uXXXX pairs for characters above U+FFFF are joined by the encode/decode round trip
300+ out = re.sub(r"\\(u[0-9a-fA-F]{4}|.)", one, s)
301+ return out.encode("utf-16", "surrogatepass").decode("utf-16")
302+
303+
304+def read_properties(path):
305+ """Reader for the one-line key=value files this script writes."""
306+ props = {}
307+ if not path.is_file():
308+ return props
309+ for line in path.read_text(encoding="latin-1").splitlines():
310+ line = line.lstrip()
311+ if not line or line[0] in "#!":
312+ continue
313+ m = re.match(r"((?:\\.|[^=:\s])+)\s*[=:]?\s*(.*)$", line)
314+ if m:
315+ props[prop_unescape(m.group(1))] = prop_unescape(m.group(2))
316+ return props
317+
318+
319+# --- minimal PNG writer (RGBA, standard library only) -----------------------------------
320+def png_bytes(w, h, px):
321+ def chunk(typ, data):
322+ return (struct.pack(">I", len(data)) + typ + data
323+ + struct.pack(">I", zlib.crc32(typ + data) & 0xffffffff))
324+ raw = bytearray()
325+ stride = w * 4
326+ for y in range(h):
327+ raw.append(0)
328+ raw += px[y * stride:(y + 1) * stride]
329+ return (b"\x89PNG\r\n\x1a\n"
330+ + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0))
331+ + chunk(b"IDAT", zlib.compress(bytes(raw), 9))
332+ + chunk(b"IEND", b""))
333+
334+
335+def png_size(path):
336+ with open(path, "rb") as f:
337+ head = f.read(24)
338+ if head[:8] != b"\x89PNG\r\n\x1a\n" or head[12:16] != b"IHDR":
339+ raise RebrandError("%s is not a PNG" % rel(path))
340+ return struct.unpack(">II", head[16:24])
341+
342+
343+def _blend(px, w, x, y, rgb, a):
344+ if a <= 0:
345+ return
346+ i = (y * w + x) * 4
347+ if a >= 1:
348+ px[i:i + 4] = bytes((rgb[0], rgb[1], rgb[2], 255))
349+ return
350+ da = px[i + 3] / 255.0
351+ oa = a + da * (1 - a)
352+ for k in range(3):
353+ px[i + k] = int(round((rgb[k] * a + px[i + k] * da * (1 - a)) / oa))
354+ px[i + 3] = int(round(oa * 255))
355+
356+
357+def _cov(d):
358+ """Pixel coverage from a signed distance in pixels (negative = inside)."""
359+ if d <= -0.5:
360+ return 1.0
361+ if d >= 0.5:
362+ return 0.0
363+ return 0.5 - d
364+
365+
366+# Shapes in units of the mark radius (26dp on the 108dp adaptive canvas), centred on (0, 0),
367+# y pointing down. No point is further than 1.21 units (31.5dp) from the centre, so the
368+# mark stays inside the 33dp safe-zone circle that every launcher mask keeps.
369+def _poly(n, radius, phase):
370+ return [(radius * math.cos(phase + 2 * math.pi * k / n),
371+ radius * math.sin(phase + 2 * math.pi * k / n)) for k in range(n)]
372+
373+
374+def shape_parts(shape):
375+ """Return a list of ('circle', r) or ('poly', [points]) parts (their union is drawn)."""
376+ if shape == "disc":
377+ return [("circle", 1.0)]
378+ if shape == "square":
379+ s = 0.85
380+ return [("poly", [(-s, -s), (s, -s), (s, s), (-s, s)])]
381+ if shape == "diamond":
382+ return [("poly", [(0, -1.05), (1.05, 0), (0, 1.05), (-1.05, 0)])]
383+ if shape == "triangle":
384+ return [("poly", _poly(3, 1.1, -math.pi / 2))]
385+ if shape == "pentagon":
386+ return [("poly", _poly(5, 1.05, -math.pi / 2))]
387+ if shape == "hexagon":
388+ return [("poly", _poly(6, 1.0, math.pi / 6))]
389+ raise ValueError(shape)
390+
391+
392+def _poly_sd(points, x, y):
393+ """Signed distance to a convex polygon given clockwise on screen (y down)."""
394+ d = -1e9
395+ n = len(points)
396+ for k in range(n):
397+ x1, y1 = points[k]
398+ x2, y2 = points[(k + 1) % n]
399+ ex, ey = x2 - x1, y2 - y1
400+ ln = (ex * ex + ey * ey) ** 0.5
401+ # outward normal of a clockwise (screen) polygon
402+ nx, ny = ey / ln, -ex / ln
403+ d = max(d, (x - x1) * nx + (y - y1) * ny)
404+ return d
405+
406+
407+def shape_sd(parts, x, y):
408+ d = 1e9
409+ for p in parts:
410+ if p[0] == "circle":
411+ dd = (x * x + y * y) ** 0.5 - p[1]
412+ else:
413+ dd = _poly_sd(p[1], x, y)
414+ d = min(d, dd)
415+ return d
416+
417+
418+def draw_mark(px, size, cx, cy, rad, fg, parts):
419+ lo_x, hi_x = int(cx - 1.2 * rad) - 1, int(cx + 1.2 * rad) + 2
420+ lo_y, hi_y = int(cy - 1.2 * rad) - 1, int(cy + 1.2 * rad) + 2
421+ for y in range(max(0, lo_y), min(size, hi_y)):
422+ for x in range(max(0, lo_x), min(size, hi_x)):
423+ d = shape_sd(parts, (x + 0.5 - cx) / rad, (y + 0.5 - cy) / rad) * rad
424+ _blend(px, size, x, y, fg, _cov(d))
425+
426+
427+def render_png(kind, size, bg, fg, parts):
428+ px = bytearray(size * size * 4)
429+ c = size / 2.0
430+ if kind in ("square", "round"):
431+ # legacy (pre-Android 8) launcher icon: shape with a small margin, mark on top
432+ half = size * 0.46
433+ corner = size * 0.12
434+ for y in range(size):
435+ for x in range(size):
436+ u, v = x + 0.5 - c, y + 0.5 - c
437+ if kind == "round":
438+ d = (u * u + v * v) ** 0.5 - half
439+ else:
440+ dx, dy = abs(u) - (half - corner), abs(v) - (half - corner)
441+ if dx < 0 and dy < 0:
442+ d = max(dx, dy) - corner
443+ else:
444+ d = (max(dx, 0) ** 2 + max(dy, 0) ** 2) ** 0.5 - corner
445+ _blend(px, size, x, y, bg, _cov(d))
446+ draw_mark(px, size, c, c, size * 0.26, fg, parts)
447+ elif kind == "bg_layer":
448+ for i in range(0, len(px), 4):
449+ px[i:i + 4] = bytes((bg[0], bg[1], bg[2], 255))
450+ elif kind == "fg_layer":
451+ # same geometry as the vector foreground: radius 26 on the 108 canvas
452+ draw_mark(px, size, c, c, size * 26.0 / 108.0, fg, parts)
453+ elif kind == "notif":
454+ # white disc with the mark cut out, like the stock icon (the system tints it);
455+ # disc and mark keep the launcher's proportions (mark radius 26 : visible radius 36)
456+ disc = size * 0.458
457+ rad = disc * 26.0 / 36.0
458+ for y in range(size):
459+ for x in range(size):
460+ u, v = x + 0.5 - c, y + 0.5 - c
461+ a = _cov((u * u + v * v) ** 0.5 - disc)
462+ if a > 0:
463+ a *= 1.0 - _cov(shape_sd(parts, u / rad, v / rad) * rad)
464+ i = (y * size + x) * 4
465+ px[i:i + 4] = bytes((255, 255, 255, int(round(a * 255))))
466+ else:
467+ raise ValueError(kind)
468+ return png_bytes(size, size, px)
469+
470+
471+# --- vector layers (108dp adaptive canvas) ----------------------------------------------
472+def _circle_path(cx, cy, r):
473+ return ("M%.2f,%.2f A%.2f,%.2f 0 1,1 %.2f,%.2f A%.2f,%.2f 0 1,1 %.2f,%.2f Z"
474+ % (cx - r, cy, r, r, cx + r, cy, r, r, cx - r, cy))
475+
476+
477+def vector_layer(parts, color):
478+ cx = cy = 54.0
479+ rad = 26.0
480+ paths = []
481+ for p in parts:
482+ if p[0] == "circle":
483+ d = _circle_path(cx, cy, p[1] * rad)
484+ else:
485+ pts = [(cx + x * rad, cy + y * rad) for x, y in p[1]]
486+ d = "M" + " L".join("%.2f,%.2f" % pt for pt in pts) + " Z"
487+ paths.append(d)
488+ body = "\n".join(' <path\n android:fillColor="%s"\n android:pathData="%s" />'
489+ % (color, d) for d in paths)
490+ return ('<?xml version="1.0" encoding="utf-8"?>\n'
491+ '<!-- Generated by multigram/rebrand/generate_rebrand.py -->\n'
492+ '<vector xmlns:android="http://schemas.android.com/apk/res/android"\n'
493+ ' android:width="108dp"\n'
494+ ' android:height="108dp"\n'
495+ ' android:viewportWidth="108"\n'
496+ ' android:viewportHeight="108">\n'
497+ '%s\n'
498+ '</vector>\n' % body)
499+
500+
501+def splash_vector(parts, bg, fg):
502+ """The splash-screen icon on its 320dp canvas: the launcher icon as a disc of radius 100
503+ (the 36dp visible radius of the adaptive icon, scaled), mark in the same proportion."""
504+ cx = cy = 160.0
505+ scale = 100.0 / 36.0
506+ rad = 26.0 * scale
507+ paths = [(hexc(bg), _circle_path(cx, cy, 100.0))]
508+ for p in parts:
509+ if p[0] == "circle":
510+ d = _circle_path(cx, cy, p[1] * rad)
511+ else:
512+ pts = [(cx + x * rad, cy + y * rad) for x, y in p[1]]
513+ d = "M" + " L".join("%.2f,%.2f" % pt for pt in pts) + " Z"
514+ paths.append((hexc(fg), d))
515+ body = "\n".join(' <path\n android:fillColor="%s"\n android:pathData="%s" />'
516+ % cd for cd in paths)
517+ return ('<?xml version="1.0" encoding="utf-8"?>\n'
518+ '<!-- Generated by multigram/rebrand/generate_rebrand.py -->\n'
519+ '<vector xmlns:android="http://schemas.android.com/apk/res/android"\n'
520+ ' android:width="320dp"\n'
521+ ' android:height="320dp"\n'
522+ ' android:viewportWidth="320"\n'
523+ ' android:viewportHeight="320">\n'
524+ '%s\n'
525+ '</vector>\n' % body)
526+
527+
528+ADAPTIVE_ICON = ('<?xml version="1.0" encoding="utf-8"?>\n'
529+ '<!-- Generated by multigram/rebrand/generate_rebrand.py -->\n'
530+ '<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">\n'
531+ ' <background android:drawable="@color/multigram_rebrand_icon_bg" />\n'
532+ ' <foreground android:drawable="@drawable/multigram_rebrand_icon_fg" />\n'
533+ ' <monochrome android:drawable="@drawable/multigram_rebrand_icon_mono" />\n'
534+ '</adaptive-icon>\n')
535+
536+
537+# --- scanning the tree ------------------------------------------------------------------
538+def res_roots():
539+ roots = []
540+ for g in RES_ROOT_GLOBS:
541+ roots += sorted(p for p in REPO.glob(g) if p.is_dir())
542+ return roots
543+
544+
545+def scan_icons():
546+ """{(type, name): {qualifier_dir: (ext, (w, h) or None)}} for every ICON_TARGETS entry
547+ defined in the tree."""
548+ found = {}
549+ for root in res_roots():
550+ for d in sorted(root.iterdir()):
551+ if not d.is_dir():
552+ continue
553+ rtype = d.name.split("-", 1)[0]
554+ for f in sorted(d.iterdir()):
555+ name, _, ext = f.name.partition(".")
556+ key = (rtype, name)
557+ if key not in ICON_TARGETS:
558+ continue
559+ info = (ext, png_size(f) if ext == "png" else None)
560+ prev = found.setdefault(key, {}).get(d.name)
561+ if prev is not None and prev[0] != ext:
562+ raise RebrandError("%s/%s exists as .%s and .%s in different source sets"
563+ % (d.name, name, prev[0], ext))
564+ if prev is None or (info[1] and prev[1] and info[1][0] > prev[1][0]):
565+ found[key][d.name] = info
566+ return found
567+
568+
569+def scan_app_name_dirs():
570+ """values* directories whose strings.xml defines one of APP_NAME_STRINGS."""
571+ dirs = {"values"}
572+ for root in res_roots():
573+ for f in sorted(root.glob("values*/strings.xml")):
574+ try:
575+ names = {e.get("name") for e in ET.parse(f).getroot().iter("string")}
576+ except ET.ParseError as e:
577+ raise RebrandError("cannot parse %s: %s" % (rel(f), e))
578+ if names & set(APP_NAME_STRINGS):
579+ dirs.add(f.parent.name)
580+ return sorted(dirs)
581+
582+
583+def check_manifests(icons):
584+ """Warn when a label or launcher icon comes from a resource the overlay does not cover
585+ (e.g. Forkgram renamed its default icon)."""
586+ warnings = []
587+ files = [REPO / m for m in MANIFESTS] + sorted(REPO.glob(CONFIG_MANIFEST_GLOB))
588+ for f in files:
589+ if not f.is_file():
590+ continue
591+ try:
592+ root = ET.parse(f).getroot()
593+ except ET.ParseError as e:
594+ raise RebrandError("cannot parse %s: %s" % (rel(f), e))
595+ app = root.find("application")
596+ if app is None:
597+ continue
598+ nodes = [app]
599+ for alias in app.findall("activity-alias"):
600+ enabled = alias.get(ANDROID_NS + "enabled", "true") == "true"
601+ launcher = any(c.get(ANDROID_NS + "name") == "android.intent.category.LAUNCHER"
602+ for c in alias.iter("category"))
603+ if enabled and launcher:
604+ nodes.append(alias)
605+ for node in nodes:
606+ label = node.get(ANDROID_NS + "label")
607+ if label and label.startswith("@string/") and label[8:] not in APP_NAME_STRINGS:
608+ warnings.append("%s: label %s is not rebranded" % (rel(f), label))
609+ for attr in ("icon", "roundIcon"):
610+ ref = node.get(ANDROID_NS + attr)
611+ if not ref or not ref.startswith("@"):
612+ continue
613+ rtype, _, name = ref[1:].partition("/")
614+ key = (rtype, name)
615+ if key in ICON_TARGETS:
616+ continue
617+ exists = any(next(r.glob("%s*/%s.*" % (rtype, name)), None) is not None
618+ for r in res_roots())
619+ if exists:
620+ warnings.append("%s: %s %s is not rebranded" % (rel(f), attr, ref))
621+ return warnings
622+
623+
624+def check_splash_styles():
625+ """Warn when a theme's splash-screen icon is a drawable the overlay does not cover."""
626+ warnings = []
627+ for root in res_roots():
628+ for f in sorted(root.glob("values*/*.xml")):
629+ try:
630+ tree = ET.parse(f)
631+ except ET.ParseError as e:
632+ raise RebrandError("cannot parse %s: %s" % (rel(f), e))
633+ for item in tree.getroot().iter("item"):
634+ ref = (item.text or "").strip()
635+ if item.get("name") != SPLASH_ATTR or not ref.startswith("@"):
636+ continue
637+ rtype, _, name = ref[1:].partition("/")
638+ if (rtype, name) not in ICON_TARGETS:
639+ warnings.append("%s: splash icon %s is not rebranded" % (rel(f), ref))
640+ return warnings
641+
642+
643+def google_services_plugin_applied():
644+ f = APP_MODULE / "build.gradle"
645+ if not f.is_file():
646+ return False
647+ for line in f.read_text(encoding="utf-8").splitlines():
648+ line = line.split("//", 1)[0]
649+ if "com.google.gms.google-services" in line:
650+ return True
651+ return False
652+
653+
654+# --- overlay ----------------------------------------------------------------------------
655+def write_overlay(app_name, bg, fg, shape, icons, name_dirs):
656+ if GEN_RES.exists():
657+ shutil.rmtree(GEN_RES)
658+ parts = shape_parts(shape)
659+ written = []
660+
661+ def put(path, data):
662+ path.parent.mkdir(parents=True, exist_ok=True)
663+ if isinstance(data, str):
664+ data = data.encode("utf-8")
665+ path.write_bytes(data)
666+ written.append(path)
667+
668+ esc = android_string(app_name)
669+ strings = "".join(' <string name="%s">%s</string>\n' % (n, esc) for n in APP_NAME_STRINGS)
670+ for d in name_dirs:
671+ put(GEN_RES / d / "rebrand_strings.xml",
672+ '<?xml version="1.0" encoding="utf-8"?>\n'
673+ '<!-- Generated by multigram/rebrand/generate_rebrand.py -->\n'
674+ '<resources>\n%s</resources>\n' % strings)
675+ put(GEN_RES / "values" / "rebrand_colors.xml",
676+ '<?xml version="1.0" encoding="utf-8"?>\n'
677+ '<!-- Generated by multigram/rebrand/generate_rebrand.py -->\n'
678+ '<resources>\n'
679+ ' <color name="multigram_rebrand_icon_bg">%s</color>\n'
680+ '</resources>\n' % hexc(bg))
681+ put(GEN_RES / "drawable" / "multigram_rebrand_icon_fg.xml", vector_layer(parts, hexc(fg)))
682+ put(GEN_RES / "drawable" / "multigram_rebrand_icon_mono.xml", vector_layer(parts, "#FFFFFFFF"))
683+ # Read by org.telegram.messenger.multigram.Rebrand (default false in the library).
684+ put(GEN_RES / "values" / "rebrand_flags.xml",
685+ '<?xml version="1.0" encoding="utf-8"?>\n'
686+ '<!-- Generated by multigram/rebrand/generate_rebrand.py -->\n'
687+ '<resources>\n'
688+ ' <bool name="multigram_rebrand_active">true</bool>\n'
689+ '</resources>\n')
690+
691+ cache = {}
692+ for (rtype, name), kind in sorted(ICON_TARGETS.items()):
693+ for qdir, (ext, size) in sorted(icons.get((rtype, name), {}).items()):
694+ if ext == "xml" and kind == "splash":
695+ put(GEN_RES / qdir / (name + ".xml"), splash_vector(parts, bg, fg))
696+ elif ext == "xml":
697+ m = re.search(r"-v(\d+)", qdir)
698+ if kind not in ("square", "round") or "anydpi" not in qdir or not m \
699+ or int(m.group(1)) < 26:
700+ raise RebrandError("unexpected %s/%s.xml: update ICON_TARGETS in %s"
701+ % (qdir, name, rel(__file__)))
702+ put(GEN_RES / qdir / (name + ".xml"), ADAPTIVE_ICON)
703+ elif ext == "png" and kind != "splash":
704+ w, h = size
705+ if w != h:
706+ raise RebrandError("%s/%s.png is not square (%dx%d)" % (qdir, name, w, h))
707+ k = (kind, w)
708+ if k not in cache:
709+ cache[k] = render_png(kind, w, bg, fg, parts)
710+ put(GEN_RES / qdir / (name + ".png"), cache[k])
711+ else:
712+ raise RebrandError("unexpected %s/%s.%s: update ICON_TARGETS in %s"
713+ % (qdir, name, ext, rel(__file__)))
714+ return written
715+
716+
717+# --- in-place patches with backups ------------------------------------------------------
718+def load_index():
719+ if BACKUP_INDEX.is_file():
720+ return json.loads(BACKUP_INDEX.read_text(encoding="utf-8"))
721+ return {}
722+
723+
724+def save_index(idx):
725+ BACKUP_INDEX.parent.mkdir(parents=True, exist_ok=True)
726+ BACKUP_INDEX.write_text(json.dumps(idx, indent=2, sort_keys=True) + "\n", encoding="utf-8")
727+
728+
729+def original_bytes(path, idx):
730+ """The stock content of a file this script may have patched before: the backup while
731+ the file still holds our last patch, otherwise the file itself. Refuses a file that
732+ changed after we patched it, or that still carries a patch whose backup is gone, since
733+ neither is the stock original any more."""
734+ cur = path.read_bytes()
735+ entry = idx.get(rel(path))
736+ bak = BACKUP / rel(path)
737+ if entry:
738+ if sha256(cur) == entry["patched"]:
739+ if not bak.is_file() or sha256(bak.read_bytes()) != entry["orig"]:
740+ raise RebrandError("the backup of %s is missing or damaged; restore the file "
741+ "(git checkout -- %s) and run again" % (rel(path), rel(path)))
742+ return bak.read_bytes()
743+ if sha256(cur) != entry["orig"]:
744+ raise RebrandError("%s changed after the rebrand patched it, so it is not the stock "
745+ "file any more; restore it (git checkout -- %s), or put your "
746+ "change back after --clean, and run again" % (rel(path), rel(path)))
747+ if PATCH_MARKER.encode() in cur:
748+ raise RebrandError("%s still carries an earlier rebrand's patch and its backup is gone "
749+ "(was generated/ deleted?); restore it (git checkout -- %s) and run "
750+ "again" % (rel(path), rel(path)))
751+ return cur
752+
753+
754+def add_marker(data, xml):
755+ """Put PATCH_MARKER at the top of a patched file (after an XML declaration)."""
756+ if not xml:
757+ return ("// %s; undo with --clean\n" % PATCH_MARKER).encode() + data
758+ # (an XML comment must not contain "--", so the option is not spelled out here)
759+ line = ("<!-- %s; its clean option undoes this -->\n" % PATCH_MARKER).encode()
760+ m = re.match(rb"<\?xml[^>]*\?>\r?\n?", data)
761+ if m:
762+ head = m.group(0) if m.group(0).endswith(b"\n") else m.group(0) + b"\n"
763+ return head + line + data[m.end():]
764+ return line + data
765+
766+
767+def plan_account_type(account_type, idx):
768+ """Compute the patched bytes of the three files without writing anything."""
769+ plan = []
770+ notes = []
771+ if not CONTACTS_JAVA.is_file():
772+ raise RebrandError("%s is missing" % rel(CONTACTS_JAVA))
773+ orig = original_bytes(CONTACTS_JAVA, idx)
774+ old = b'"' + STOCK_JAVA_ACCOUNT_TYPE.encode() + b'"'
775+ n = orig.count(old)
776+ if n == 0:
777+ raise RebrandError('%s has no "%s" literal left; restore it (git checkout -- %s) '
778+ "and run again" % (rel(CONTACTS_JAVA), STOCK_JAVA_ACCOUNT_TYPE,
779+ rel(CONTACTS_JAVA)))
780+ new = add_marker(orig.replace(old, b'"' + account_type.encode() + b'"'), xml=False)
781+ note = "%d literal(s)" % n
782+ if n != JAVA_EXPECTED_LITERALS:
783+ note += " (expected %d: check the file)" % JAVA_EXPECTED_LITERALS
784+ plan.append((CONTACTS_JAVA, orig, new, note))
785+ for x in ACCOUNT_XMLS:
786+ if not x.is_file():
787+ raise RebrandError("%s is missing" % rel(x))
788+ orig = original_bytes(x, idx)
789+ found = ACCOUNT_XML_ATTR.findall(orig)
790+ if len(found) != 1:
791+ raise RebrandError("%s: expected one android:accountType, found %d"
792+ % (rel(x), len(found)))
793+ was = found[0][len(b'android:accountType="'):-1]
794+ if was != STOCK_XML_ACCOUNT_TYPE:
795+ notes.append("%s had android:accountType=\"%s\" before patching (stock is %s); "
796+ "--clean puts that value back" % (rel(x), was.decode("utf-8", "replace"),
797+ STOCK_XML_ACCOUNT_TYPE.decode()))
798+ new = ACCOUNT_XML_ATTR.sub(b'android:accountType="' + account_type.encode() + b'"', orig)
799+ plan.append((x, orig, add_marker(new, xml=True), "android:accountType"))
800+ return plan, notes
801+
802+
803+def patched_files_left():
804+ """The account-type files that still carry PATCH_MARKER."""
805+ return [rel(f) for f in [CONTACTS_JAVA] + ACCOUNT_XMLS
806+ if f.is_file() and PATCH_MARKER.encode() in f.read_bytes()]
807+
808+
809+def apply_plan(plan, idx):
810+ for path, orig, new, _ in plan:
811+ bak = BACKUP / rel(path)
812+ bak.parent.mkdir(parents=True, exist_ok=True)
813+ bak.write_bytes(orig)
814+ path.write_bytes(new)
815+ idx[rel(path)] = {"orig": sha256(orig), "patched": sha256(new)}
816+ save_index(idx)
817+
818+
819+def restore_patched(idx, only_check=False):
820+ """Put back the stock files. Refuses (returns the list) when a patched file has been
821+ changed since this script wrote it, so nobody's edits are overwritten."""
822+ conflicts = []
823+ todo = []
824+ for relpath, entry in sorted(idx.items()):
825+ p = REPO / relpath
826+ bak = BACKUP / relpath
827+ cur = sha256(p.read_bytes()) if p.is_file() else None
828+ if cur == entry["orig"]:
829+ continue
830+ if cur == entry["patched"] and bak.is_file() and sha256(bak.read_bytes()) == entry["orig"]:
831+ todo.append((p, bak))
832+ else:
833+ conflicts.append(relpath)
834+ if conflicts or only_check:
835+ return conflicts, 0
836+ for p, bak in todo:
837+ p.write_bytes(bak.read_bytes())
838+ return [], len(todo)
839+
840+
841+def unrestorable_patches(idx):
842+ """Files that carry PATCH_MARKER but that restore_patched cannot put back, because
843+ their backup is gone (e.g. generated/ was deleted) or they changed since."""
844+ bad = []
845+ for relpath in patched_files_left():
846+ entry = idx.get(relpath)
847+ cur = sha256((REPO / relpath).read_bytes())
848+ bak = BACKUP / relpath
849+ if not (entry and cur == entry["patched"] and bak.is_file()
850+ and sha256(bak.read_bytes()) == entry["orig"]):
851+ bad.append(relpath)
852+ return bad
853+
854+
855+def stock_account_type_warnings():
856+ """After a restore: say so when the three files do not hold Forkgram's values."""
857+ out = []
858+ if CONTACTS_JAVA.is_file() and \
859+ ('"%s"' % STOCK_JAVA_ACCOUNT_TYPE).encode() not in CONTACTS_JAVA.read_bytes():
860+ out.append('%s has no "%s" literal' % (rel(CONTACTS_JAVA), STOCK_JAVA_ACCOUNT_TYPE))
861+ for x in ACCOUNT_XMLS:
862+ if x.is_file() and b'android:accountType="' + STOCK_XML_ACCOUNT_TYPE + b'"' \
863+ not in x.read_bytes():
864+ out.append('%s does not say android:accountType="%s"'
865+ % (rel(x), STOCK_XML_ACCOUNT_TYPE.decode()))
866+ return out
867+
868+
869+def warn_leftover_account_types():
870+ """Report other files that still carry an account type, e.g. after an upstream change
871+ (reports only: they may be unrelated, so they are never edited)."""
872+ hits = []
873+ java_lit = ('"%s"' % STOCK_JAVA_ACCOUNT_TYPE).encode()
874+ patched = {CONTACTS_JAVA.resolve()} | {x.resolve() for x in ACCOUNT_XMLS}
875+ for root in [LIB_MODULE / "src", APP_MODULE / "src"]:
876+ if not root.is_dir():
877+ continue
878+ for p in root.rglob("*"):
879+ if not p.is_file() or p.resolve() in patched or p.suffix not in (".java", ".kt", ".xml"):
880+ continue
881+ try:
882+ data = p.read_bytes()
883+ except OSError:
884+ continue
885+ if p.suffix == ".xml":
886+ if ACCOUNT_XML_ATTR.search(data):
887+ hits.append(rel(p))
888+ elif java_lit in data:
889+ hits.append(rel(p))
890+ return hits
891+
892+
893+# --- keystore ---------------------------------------------------------------------------
894+def keytool():
895+ exe = shutil.which("keytool")
896+ if not exe and os.environ.get("JAVA_HOME"):
897+ cand = Path(os.environ["JAVA_HOME"]) / "bin" / "keytool"
898+ if cand.is_file():
899+ exe = str(cand)
900+ if not exe:
901+ raise RebrandError("keytool not found (install a JDK, set JAVA_HOME) or use --no-keystore")
902+ return exe
903+
904+
905+def keystore_fingerprint(path, store_pw, alias):
906+ out = subprocess.run([keytool(), "-list", "-v", "-keystore", str(path), "-storepass",
907+ store_pw, "-alias", alias], capture_output=True, text=True)
908+ if out.returncode != 0:
909+ raise RebrandError("keytool cannot open %s: %s" % (path, (out.stdout + out.stderr).strip()))
910+ m = re.search(r"SHA256:\s*([0-9A-F:]+)", out.stdout)
911+ return m.group(1) if m else "?"
912+
913+
914+def make_keystore(path, cn):
915+ """Create a fresh RSA key. PKCS12 keystores use one password for store and key."""
916+ if path.exists():
917+ path.unlink()
918+ path.parent.mkdir(parents=True, exist_ok=True)
919+ alias = "k" + secrets.token_hex(5)
920+ pw = secrets.token_urlsafe(24)
921+ safe_cn = re.sub(r"[^A-Za-z0-9 ]", "", cn).strip() or "Rebrand"
922+ cmd = [keytool(), "-genkeypair", "-noprompt", "-storetype", "PKCS12",
923+ "-keystore", str(path), "-alias", alias, "-keyalg", "RSA", "-keysize", "3072",
924+ "-validity", "10000", "-storepass", pw, "-keypass", pw,
925+ "-dname", "CN=%s, O=%s" % (safe_cn, safe_cn)]
926+ out = subprocess.run(cmd, capture_output=True, text=True)
927+ if out.returncode != 0:
928+ raise RebrandError("keytool failed: %s" % (out.stdout + out.stderr).strip())
929+ os.chmod(path, 0o600)
930+ return {"alias": alias, "store_password": pw, "key_password": pw}
931+
932+
933+def keystore_for(args, app_name):
934+ """Returns (path, creds, note) or None for --no-keystore."""
935+ if args.no_keystore:
936+ return None
937+ if args.keystore:
938+ path = Path(args.keystore).expanduser().resolve()
939+ side = path.with_name(path.name + ".properties")
940+ try:
941+ path.relative_to(REPO)
942+ raise RebrandError("keep the --keystore file outside the repository (%s), so the "
943+ "private key can never be committed" % REPO)
944+ except ValueError:
945+ pass
946+ if path.exists():
947+ p = read_properties(side)
948+ if not all(k in p for k in ("KEY_ALIAS", "STORE_PASSWORD", "KEY_PASSWORD")):
949+ raise RebrandError("%s exists but %s with KEY_ALIAS, STORE_PASSWORD and "
950+ "KEY_PASSWORD is missing" % (path, side))
951+ creds = {"alias": p["KEY_ALIAS"], "store_password": p["STORE_PASSWORD"],
952+ "key_password": p["KEY_PASSWORD"]}
953+ fp = keystore_fingerprint(path, creds["store_password"], creds["alias"])
954+ return path, creds, "reused %s (SHA-256 %s)" % (path, fp)
955+ creds = make_keystore(path, app_name)
956+ side.write_text("# Credentials for %s. Keep both files together and private.\n"
957+ "KEY_ALIAS=%s\nSTORE_PASSWORD=%s\nKEY_PASSWORD=%s\n"
958+ % (prop_escape(path.name), prop_escape(creds["alias"]),
959+ prop_escape(creds["store_password"]),
960+ prop_escape(creds["key_password"])), encoding="latin-1")
961+ os.chmod(side, 0o600)
962+ fp = keystore_fingerprint(path, creds["store_password"], creds["alias"])
963+ return path, creds, "created %s (SHA-256 %s); reuse it for every update" % (path, fp)
964+ creds = make_keystore(DEFAULT_KEYSTORE, app_name)
965+ fp = keystore_fingerprint(DEFAULT_KEYSTORE, creds["store_password"], creds["alias"])
966+ return DEFAULT_KEYSTORE, creds, ("new key (SHA-256 %s); --clean deletes it, see the "
967+ "README before shipping updatable builds" % fp)
968+
969+
970+# --- commands ---------------------------------------------------------------------------
971+def clean():
972+ idx = load_index()
973+ conflicts, _ = restore_patched(idx, only_check=True)
974+ if conflicts:
975+ raise RebrandError("not cleaning: these files changed after the rebrand patched them, "
976+ "so restoring the backup would lose those changes:\n "
977+ + "\n ".join(conflicts)
978+ + "\nResolve them (e.g. git checkout -- <file>) and run --clean again.")
979+ lost = unrestorable_patches(idx)
980+ if lost:
981+ raise RebrandError("not cleaning: these files still carry a rebrand's account type and "
982+ "there is no backup to restore them from (was generated/ deleted?):"
983+ "\n " + "\n ".join(lost)
984+ + "\nRestore them with: git checkout -- " + " ".join(lost)
985+ + "\nthen run --clean again.")
986+ _, restored = restore_patched(idx)
987+ left = patched_files_left()
988+ if left:
989+ raise RebrandError("still patched after restoring: " + ", ".join(left))
990+ had_key = DEFAULT_KEYSTORE.exists()
991+ if PROPS.exists():
992+ PROPS.unlink()
993+ if GEN.exists():
994+ shutil.rmtree(GEN)
995+ print("Rebrand removed: stock identity restored (%d source file(s) put back)." % restored)
996+ if had_key:
997+ print("The generated signing key was deleted with it.")
998+ for w in stock_account_type_warnings():
999+ print("WARNING: %s (not Forkgram's stock value; check the file)" % w)
1000+
1001+
1002+def generate(args):
1003+ seed = args.seed if args.seed is not None else secrets.token_hex(8)
1004+ ident = gen_identity(rng_from_seed(seed))
1005+ app_id = args.application_id or ident["application_id"]
1006+ app_name = args.app_name or ident["app_name"]
1007+ check_package("applicationId", app_id)
1008+ check_app_name(app_name)
1009+ account_type = None
1010+ if not args.no_account_type:
1011+ account_type = args.account_type or app_id
1012+ check_package("account type", account_type)
1013+ bg, fg, shape = ident["bg"], ident["fg"], ident["shape"]
1014+
1015+ # Check everything before writing anything.
1016+ icons = scan_icons()
1017+ missing = [n for (t, n) in ICON_TARGETS if (t, n) not in icons]
1018+ name_dirs = scan_app_name_dirs()
1019+ warnings = check_manifests(icons) + check_splash_styles()
1020+ warnings += ["icon resource %s not found in the tree (skipped)" % n for n in missing]
1021+ if google_services_plugin_applied():
1022+ warnings.append("TMessagesProj_App applies the google-services plugin now: its "
1023+ "google-services.json must list %s, %s.beta, %s.web (and your "
1024+ "flavor ids); use your own Firebase project" % (app_id, app_id, app_id))
1025+ idx = load_index()
1026+ plan, notes = plan_account_type(account_type, idx) if account_type else ([], [])
1027+ warnings += notes
1028+ if not account_type:
1029+ conflicts, _ = restore_patched(idx, only_check=True)
1030+ if conflicts:
1031+ raise RebrandError("cannot put back the stock account type, these files changed "
1032+ "since: " + ", ".join(conflicts))
1033+ lost = unrestorable_patches(idx)
1034+ if lost:
1035+ raise RebrandError("cannot put back the stock account type, there is no backup "
1036+ "for: %s; restore them with git checkout -- %s and run again"
1037+ % (", ".join(lost), " ".join(lost)))
1038+
1039+ GEN.mkdir(parents=True, exist_ok=True)
1040+ ks = keystore_for(args, app_name)
1041+ write_overlay(app_name, bg, fg, shape, icons, name_dirs)
1042+ if plan:
1043+ apply_plan(plan, idx)
1044+ else:
1045+ restore_patched(idx)
1046+ for relpath in list(idx):
1047+ (BACKUP / relpath).unlink(missing_ok=True)
1048+ save_index({})
1049+ leftovers = warn_leftover_account_types() if account_type else []
1050+
1051+ # The options that repeat this identity, for rebrand.gradle's error messages.
1052+ rerun = ["--seed", seed]
1053+ for opt, val in (("--app-name", args.app_name), ("--application-id", args.application_id),
1054+ ("--account-type", args.account_type),
1055+ ("--version-name", args.version_name)):
1056+ if val:
1057+ rerun += [opt, val]
1058+ if args.version_code:
1059+ rerun += ["--version-code", str(args.version_code)]
1060+ if args.no_account_type:
1061+ rerun.append("--no-account-type")
1062+ if args.keystore:
1063+ rerun += ["--keystore", str(Path(args.keystore).expanduser().resolve())]
1064+ elif args.no_keystore:
1065+ rerun.append("--no-keystore")
1066+
1067+ lines = [
1068+ "# Generated by multigram/rebrand/generate_rebrand.py. Do not commit.",
1069+ "# To build the stock identity again run generate_rebrand.py --clean. Deleting this",
1070+ "# file alone leaves three source files patched, and Gradle then refuses to build.",
1071+ "REBRAND_ACTIVE=true",
1072+ "REBRAND_SEED=" + prop_escape(seed),
1073+ "REBRAND_RERUN=" + prop_escape(shlex.join(rerun)),
1074+ "REBRAND_APPLICATION_ID=" + app_id,
1075+ "REBRAND_APP_NAME=" + prop_escape(app_name),
1076+ "REBRAND_ICON=%s %s %s" % (shape, hexc(bg), hexc(fg)),
1077+ ]
1078+ if account_type:
1079+ lines.append("REBRAND_ACCOUNT_TYPE=" + account_type)
1080+ if args.version_name:
1081+ lines.append("REBRAND_VERSION_NAME=" + prop_escape(args.version_name))
1082+ if args.version_code:
1083+ lines.append("REBRAND_VERSION_CODE=%d" % args.version_code)
1084+ if ks:
1085+ path, creds, _ = ks
1086+ try:
1087+ ks_ref = path.resolve().relative_to(REPO).as_posix()
1088+ except ValueError:
1089+ ks_ref = str(path.resolve())
1090+ lines += ["REBRAND_KEYSTORE=" + prop_escape(ks_ref),
1091+ "REBRAND_KEY_ALIAS=" + prop_escape(creds["alias"]),
1092+ "REBRAND_STORE_PASSWORD=" + prop_escape(creds["store_password"]),
1093+ "REBRAND_KEY_PASSWORD=" + prop_escape(creds["key_password"])]
1094+ PROPS.write_text("\n".join(lines) + "\n", encoding="latin-1")
1095+ os.chmod(PROPS, 0o600)
1096+
1097+ print("Rebrand identity")
1098+ print(" seed %s" % seed)
1099+ print(" applicationId %s (Gradle adds the build type suffix, e.g. .beta)" % app_id)
1100+ print(" app name %s" % app_name)
1101+ print(" icon %s, background %s, mark %s" % (shape, hexc(bg), hexc(fg)))
1102+ if account_type:
1103+ print(" account type %s" % account_type)
1104+ for path, _, _, note in plan:
1105+ print(" %s: %s" % (rel(path), note))
1106+ else:
1107+ print(" account type left as in the tree (--no-account-type)")
1108+ print(" signing %s" % (ks[2] if ks else "the module's own config (--no-keystore); "
1109+ "without RELEASE_KEYSTORE_FILE that is the public test key"))
1110+ print(" overlay %s" % rel(GEN_RES))
1111+ print(" properties %s" % rel(PROPS))
1112+ for w in warnings:
1113+ print("WARNING: " + w)
1114+ for h in leftovers:
1115+ print("WARNING: %s still carries an account type; check it by hand" % h)
1116+ print()
1117+ print("Build as usual, e.g. ./gradlew :TMessagesProj_App:assembleAfatRelease")
1118+ print("Undo with: python3 multigram/rebrand/generate_rebrand.py --clean")
1119+
1120+
1121+def main(argv=None):
1122+ ap = argparse.ArgumentParser(
1123+ description="Give this MultiGram build its own identity (see multigram/rebrand/README.md).")
1124+ ap.add_argument("--seed", help="reproduce an identity (default: a new random seed)")
1125+ ap.add_argument("--app-name", help="use this app name instead of a generated one")
1126+ ap.add_argument("--application-id", help="use this applicationId instead of a generated one")
1127+ ap.add_argument("--account-type", help="Android account type (default: the applicationId)")
1128+ ap.add_argument("--no-account-type", action="store_true",
1129+ help="leave the account type files as they are in the tree")
1130+ ap.add_argument("--version-name",
1131+ help="override the APK's versionName, which Telegram's servers see; the "
1132+ "in-app version string stays the tree's (default: keep the tree's)")
1133+ ap.add_argument("--version-code", type=int,
1134+ help="override the base versionCode (default: keep the tree's)")
1135+ ap.add_argument("--keystore", metavar="PATH",
1136+ help="keep the signing key at PATH (outside the repository): created on "
1137+ "the first run, reused on later runs; --clean leaves it alone")
1138+ ap.add_argument("--no-keystore", action="store_true",
1139+ help="no new key: sign with the module's own signing config (without "
1140+ "RELEASE_KEYSTORE_FILE that is the public test key: never "
1141+ "distribute such an APK)")
1142+ ap.add_argument("--clean", action="store_true", help="undo everything this script wrote")
1143+ args = ap.parse_args(argv)
1144+ if args.keystore and args.no_keystore:
1145+ ap.error("--keystore and --no-keystore exclude each other")
1146+ if args.account_type and args.no_account_type:
1147+ ap.error("--account-type and --no-account-type exclude each other")
1148+ if args.version_code is not None and args.version_code <= 0:
1149+ ap.error("--version-code must be positive")
1150+ try:
1151+ if args.clean:
1152+ clean()
1153+ else:
1154+ generate(args)
1155+ except RebrandError as e:
1156+ print("error: %s" % e, file=sys.stderr)
1157+ return 1
1158+ return 0
1159+
1160+
1161+if __name__ == "__main__":
1162+ sys.exit(main())