on main help sign in
Add the rebrand toolkit
a1f0a5dClaude
TMessagesProj/src/main/java/org/telegram/messenger/multigram/Rebrand.javanew file+69
@@ -0,0 +1,69 @@
1+package org.telegram.messenger.multigram;
2+
3+import org.telegram.messenger.ApplicationLoader;
4+import org.telegram.messenger.FileLog;
5+import org.telegram.messenger.R;
6+import org.telegram.ui.LauncherIconController;
7+
8+import java.util.List;
9+
10+/**
11+ * What the build-time rebrand (multigram/rebrand) changes in code. The rebrand's resource
12+ * overlay sets R.bool.multigram_rebrand_active to true; a stock build keeps the library's
13+ * false, so every method here then returns Forkgram's own behaviour.
14+ */
15+public final class Rebrand {
16+
17+ private static volatile Boolean active;
18+
19+ private Rebrand() {
20+ }
21+
22+ /** True in a build made with multigram/rebrand/generate_rebrand.py. */
23+ public static boolean isActive() {
24+ Boolean a = active;
25+ if (a == null) {
26+ a = false;
27+ try {
28+ if (ApplicationLoader.applicationContext != null) {
29+ a = ApplicationLoader.applicationContext.getResources().getBoolean(R.bool.multigram_rebrand_active);
30+ active = a;
31+ }
32+ } catch (Exception e) {
33+ FileLog.e(e);
34+ }
35+ }
36+ return a;
37+ }
38+
39+ /**
40+ * The default title of the main chat list: the rebranded app name, or {@code stock}.
41+ * Read from the app's own resources, not LocaleController: cloud language packs carry
42+ * Telegram's name for AppName.
43+ */
44+ public static String defaultTitle(String stock) {
45+ if (!isActive()) {
46+ return stock;
47+ }
48+ try {
49+ return ApplicationLoader.applicationContext.getString(R.string.AppName);
50+ } catch (Exception e) {
51+ FileLog.e(e);
52+ return stock;
53+ }
54+ }
55+
56+ /**
57+ * A rebranded build offers only its own launcher icon: the other entries are Forkgram's
58+ * and Telegram's logos, which do not belong to the new identity.
59+ */
60+ public static void filterLauncherIcons(List<LauncherIconController.LauncherIcon> icons) {
61+ if (isActive()) {
62+ for (int i = icons.size() - 1; i >= 0; i--) {
63+ if (icons.get(i) != LauncherIconController.LauncherIcon.DEFAULT) {
64+ icons.remove(i);
65+ }
66+ }
67+ }
68+ }
69+}
TMessagesProj/src/main/java/org/telegram/ui/Cells/AppIconsSelectorCell.java+1
@@ -151,6 +151,7 @@ public class AppIconsSelectorCell extends RecyclerListView implements Notificati
151 private void updateIconsVisibility() {
151 private void updateIconsVisibility() {
152 availableIcons.clear();
152 availableIcons.clear();
153 availableIcons.addAll(Arrays.asList(LauncherIconController.LauncherIcon.values()));
153 availableIcons.addAll(Arrays.asList(LauncherIconController.LauncherIcon.values()));
154+ org.telegram.messenger.multigram.Rebrand.filterLauncherIcons(availableIcons); // MultiGram: a rebranded build offers only its own icon
154 if (MessagesController.getInstance(currentAccount).premiumFeaturesBlocked()) {
155 if (MessagesController.getInstance(currentAccount).premiumFeaturesBlocked()) {
155 for (int i = 0; i < availableIcons.size(); i++) {
156 for (int i = 0; i < availableIcons.size(); i++) {
156 if (availableIcons.get(i).premium) {
157 if (availableIcons.get(i).premium) {
TMessagesProj/src/main/java/org/telegram/ui/DialogsActivity.java+2−1
@@ -3510,7 +3510,8 @@ public class DialogsActivity extends BaseFragment implements NotificationCenter.
3510 SpannableStringBuilder ssb = new SpannableStringBuilder(getString(R.string.AppName));
3510 SpannableStringBuilder ssb = new SpannableStringBuilder(getString(R.string.AppName));
3511 ssb.setSpan(new ImageSpan(logoDrawable), 0, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
3511 ssb.setSpan(new ImageSpan(logoDrawable), 0, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
3512 actionBar.setTitle(ssb, statusDrawable);
3512 actionBar.setTitle(ssb, statusDrawable);
3513+ // MultiGram: a rebranded build's default title is its own app name.
3513− actionBar.setTitle(MessagesController.getGlobalMainSettings().getString("forkCustomTitle", "Fork Client"));
3514+ actionBar.setTitle(MessagesController.getGlobalMainSettings().getString("forkCustomTitle", org.telegram.messenger.multigram.Rebrand.defaultTitle("Fork Client")));
3514 actionBar.setTitleLongClickListener(v -> {
3515 actionBar.setTitleLongClickListener(v -> {
3515 boolean mainTabsHidden = !UserConfig.getInstance(currentAccount).getMainTabsHiddenFork();
3516 boolean mainTabsHidden = !UserConfig.getInstance(currentAccount).getMainTabsHiddenFork();
3516 UserConfig.getInstance(currentAccount).setMainTabsHiddenFork(mainTabsHidden);
3517 UserConfig.getInstance(currentAccount).setMainTabsHiddenFork(mainTabsHidden);
TMessagesProj/src/main/java/org/telegram/ui/ForkSettingsActivity.java+3−2
@@ -555,7 +555,8 @@ public class ForkSettingsActivity extends BaseFragment {
555 items.add(UItem.asButtonCheck(ID_HIDE_BOTTOM_BUTTON, LocaleController.getString(R.string.HideBottomButton), LocaleController.getString(R.string.HideBottomButtonInfo))
555 items.add(UItem.asButtonCheck(ID_HIDE_BOTTOM_BUTTON, LocaleController.getString(R.string.HideBottomButton), LocaleController.getString(R.string.HideBottomButtonInfo))
556 .setChecked(pref("hideBottomButton", false)).setMultiline(true));
556 .setChecked(pref("hideBottomButton", false)).setMultiline(true));
557 }
557 }
558+ // MultiGram: a rebranded build's default title is its own app name.
558− items.add(UItem.asSettingsCell(ID_CUSTOM_TITLE, LocaleController.getString(R.string.EditAdminRank), prefs().getString("forkCustomTitle", "Fork Client")));
559+ items.add(UItem.asSettingsCell(ID_CUSTOM_TITLE, LocaleController.getString(R.string.EditAdminRank), prefs().getString("forkCustomTitle", org.telegram.messenger.multigram.Rebrand.defaultTitle("Fork Client"))));
559 items.add(UItem.asShadow(null));
560 items.add(UItem.asShadow(null));
560
561
561 items.add(UItem.asHeader(LocaleController.getString(R.string.AvatarShape)));
562 items.add(UItem.asHeader(LocaleController.getString(R.string.AvatarShape)));
@@ -1169,7 +1170,7 @@ public class ForkSettingsActivity extends BaseFragment {
1169 }
1170 }
1170
1171
1171 private void showCustomTitleDialog(View view) {
1172 private void showCustomTitleDialog(View view) {
1172− final String defaultValue = "Fork Client";
1173+ final String defaultValue = org.telegram.messenger.multigram.Rebrand.defaultTitle("Fork Client"); // MultiGram: rebranded default
1173 org.telegram.messenger.forkgram.ForkDialogs.createFieldAlert(
1174 org.telegram.messenger.forkgram.ForkDialogs.createFieldAlert(
1174 getContext(),
1175 getContext(),
1175 LocaleController.getString(R.string.EditAdminRank),
1176 LocaleController.getString(R.string.EditAdminRank),
TMessagesProj/src/main/res/values/multigram_rebrand.xmlnew file+5
@@ -0,0 +1,5 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <!-- MultiGram: set to true by the build-time rebrand's resource overlay (multigram/rebrand). -->
4+ <bool name="multigram_rebrand_active">false</bool>
5+</resources>
TMessagesProj_App/build.gradle+3
@@ -338,3 +338,6 @@ android {
338 checkReleaseBuilds false
338 checkReleaseBuilds false
339 }
339 }
340}
340}
341+
342+// MultiGram: build-time rebrand, a no-op unless multigram/rebrand/rebrand.properties exists.
343+apply from: "${rootDir}/multigram/rebrand/rebrand.gradle"
multigram/rebrand/.gitignorenew file+3
@@ -0,0 +1,3 @@
1+/generated/
2+/rebrand.properties
3+/__pycache__/
multigram/rebrand/README.mdnew file+335
@@ -0,0 +1,335 @@
1+# Rebrand toolkit
2+
3+One command gives a MultiGram build its own identity: its own applicationId, app name,
4+launcher, splash-screen and notification icons, signing key and Android account type.
5+Another command puts the stock identity back, byte for byte.
6+
7+This is white-label rebranding of your own build of open-source software (GPLv2). It
8+changes only this build's own identity and does not touch the network protocol, the app's
9+behaviour or its features. The names, ids and icons it generates come from neutral word
10+lists and plain geometric shapes. Values you pass yourself (`--app-name`,
11+`--application-id`, `--account-type`) are only checked against a short blocklist: names
12+containing "Telegram" (Telegram's API terms do not let third-party apps use it),
13+Forkgram's own names and a few other well-known apps' names, and ids in the platform's,
14+Telegram's and some well-known vendors' namespaces. Choosing a name and an id that belong
15+to you, and that do not imitate another app, is your responsibility.
16+
17+| File | What it is |
18+| --- | --- |
19+| `generate_rebrand.py` | The generator (Python 3, standard library only; `keytool` from any JDK for the key). |
20+| `rebrand.gradle` | Gradle glue, applied at the end of `TMessagesProj_App/build.gradle`. |
21+| `selftest.py` | Checks the generator on a throwaway copy of the tree. |
22+| `generated/`, `rebrand.properties` | The generator's output. Git-ignored, never committed. |
23+
24+The stack also carries `org.telegram.messenger.multigram.Rebrand` and the resource
25+`bool/multigram_rebrand_active` (false in `TMessagesProj/src/main/res/values/multigram_rebrand.xml`,
26+true in the generated overlay), for the few things that live in code (see below).
27+
28+## Quick start
29+
30+From the repository root:
31+
32+```bash
33+# A new random identity (the seed is printed and stored, so it can be repeated)
34+python3 multigram/rebrand/generate_rebrand.py
35+
36+# The same identity every time for one distribution
37+python3 multigram/rebrand/generate_rebrand.py --seed my-distribution-1
38+
39+# Pin values; keep the signing key outside the repository so later builds can update it
40+python3 multigram/rebrand/generate_rebrand.py --seed my-distribution-1 \
41+ --app-name "Quiet Cove" --application-id net.example.quietcove \
42+ --keystore ~/keys/quietcove.keystore
43+
44+# Build as usual (your own API credentials, see below)
45+./gradlew -PAPP_ID=12345 -PAPP_HASH=0123456789abcdef0123456789abcdef \
46+ :TMessagesProj_App:assembleAfatRelease
47+
48+# Back to the stock identity (the only way: see "How it wires in")
49+python3 multigram/rebrand/generate_rebrand.py --clean
50+```
51+
52+Other options: `--account-type VALUE` (default: the applicationId), `--no-account-type`
53+(leave the three account-type files alone), `--no-keystore` (sign with the module's own
54+signing config, see "Signing and updates"), `--version-name` and `--version-code`
55+(default: keep the tree's). The version override sets the APK's `versionName` /
56+`versionCode`, and that is what Telegram's servers see (the app reports it as its
57+`app_version` when it connects, and servers may gate features on it). The in-app version
58+string, Forkgram's update check and the update state compare against
59+`TMessagesProj`'s own `BuildConfig.VERSION_NAME`, which stays the tree's.
60+
61+The identity (applicationId, app name, icon, account type) is deterministic per `--seed`,
62+and an override such as `--app-name` does not change the other values for that seed. The
63+signing key is not: each run without `--keystore` makes a new one. `rebrand.properties`
64+records the options of the run in `REBRAND_RERUN`, and Gradle's error messages quote them.
65+
66+## What changes and what does not
67+
68+| Changed per build | Left as it is |
69+| --- | --- |
70+| `applicationId` (Gradle still adds `.beta` / `.web` per build type) | Java/Kotlin packages and the `namespace` (`org.telegram.messenger.regular`) |
71+| App name: `AppName`, `AppNameBeta`, `AppNameFdroid` (launcher label, account label, bundled in-app strings) and the default title of the chat list | Network protocol, API endpoints, API id/hash handling, features |
72+| Launcher icon (adaptive and legacy PNGs), the Default/Adaptive previews in the in-app icon picker, the account/contacts icon | The version, unless you pass `--version-name` / `--version-code` |
73+| Android 12+ splash-screen icon (light and dark) and the status-bar notification icon | `com.android.contacts`, the system's sync authority |
74+| Signing key | The contact-row MIME types (`vnd.org.telegram.messenger.android.*`) |
75+| Android account type (contact sync) and its "Account settings" entry | |
76+| The in-app icon picker offers only the build's own icon | |
77+
78+The applicationId and the code package are independent in Android, so the installed
79+package can differ from the source namespace without code changes.
80+
81+## How it wires in
82+
83+`TMessagesProj_App/build.gradle` ends with one hook:
84+
85+```groovy
86+// MultiGram: build-time rebrand, a no-op unless multigram/rebrand/rebrand.properties exists.
87+apply from: "${rootDir}/multigram/rebrand/rebrand.gradle"
88+```
89+
90+Without `rebrand.properties` (or without `REBRAND_ACTIVE=true` in it) the script changes
91+nothing and the module builds with Forkgram's identity, provided the tree is clean: it
92+stops the build if `ContactsController.java`, `auth.xml` or `sync_contacts.xml` still
93+carries a rebrand's patch (each patched file is marked with a
94+`MultiGram rebrand: patched by ...` comment, which the stock tree never has). That happens
95+when `rebrand.properties` is deleted by hand, so always undo a rebrand with `--clean`.
96+
97+`TMessagesProj_App` is the only app module in `settings.gradle`, so it is the only one
98+hooked. With the file present the script, running after the module's own `android {}`
99+block (AGP 8.13):
100+
101+- sets `defaultConfig.applicationId`, replacing `org.forkclient.messenger` (or
102+ `org.forkgram.messenger` with `F_DROID=1`). The build types' `applicationIdSuffix` still
103+ applies, so a normal release build installs as `<id>.beta`, as Forkgram's does, and an
104+ F-Droid build as `<id>`. The `forkTest` flavor, which has its own applicationId
105+ (`org.forkclient.messenger.test`), becomes `<id>.test` (with `F_DROID=1` too); its debug
106+ build adds `.beta` as before, `<id>.test.beta`.
107+- adds `generated/res` to the resources of every build type. Build-type resources win over
108+ the main and flavor source sets and over the `TMessagesProj` library, so the generated
109+ strings, icons and the rebrand flag replace the stock ones without duplicate-resource
110+ errors.
111+- adds the generated app names as the last input of `TelegramStringsTask` (buildSrc),
112+ which builds the in-app string assets from the `strings.xml` files.
113+- points every signing config at the generated key. If `REBRAND_KEYSTORE` names a file
114+ that is missing, the build stops (see "Signing and updates").
115+- optionally sets `versionName` / `versionCode` (the module still turns the code into
116+ `code * 10 + ABI digit`).
117+- stops the build if the account type in the three patched files does not match
118+ `rebrand.properties`, since a half-patched tree would break contact sync. The message
119+ quotes the generator command, with the recorded options, that patches them again.
120+- generates, per variant, a copy of `xml/auth_menu.xml` whose `android:targetPackage` is
121+ that variant's applicationId (task `generate<Variant>RebrandAuthMenu`).
122+
123+The lines starting with `[rebrand]` in the Gradle output list what was applied.
124+
125+## App name
126+
127+The launcher label comes from the manifest that the build type uses:
128+`TMessagesProj/config/debug/AndroidManifest_SDK23.xml` (`@string/AppNameBeta`) for normal
129+builds, `TMessagesProj/config/release/AndroidManifest_SDK23.xml` (`@string/AppName`) for
130+F-Droid builds, and `TMessagesProj_App/src/main/AndroidManifest.xml` (`@string/AppName`)
131+underneath. The account in Settings > Accounts uses `@string/AppName` too.
132+`AppNameFdroid` only feeds the `appLabel` placeholder in `TMessagesProj/build.gradle`,
133+which no manifest uses; it is set anyway. The generator writes all three into `values/`
134+and into every `values-*` folder whose `strings.xml` defines them.
135+
136+The title of the main chat list is not a resource: Forkgram shows the preference
137+`forkCustomTitle` with the literal default `"Fork Client"` (`DialogsActivity`, and the
138+title editor in `ForkSettingsActivity`). A one-line `MultiGram:` hook in each place takes
139+the default from `Rebrand.defaultTitle("Fork Client")`, which returns the app's own
140+`R.string.AppName` in a rebranded build and `"Fork Client"` otherwise. It reads the app's
141+resources, not `LocaleController`, because cloud language packs carry Telegram's name for
142+`AppName`; `AppName` stays in `resources.arsc` because `TelegramStringsTask` keeps it.
143+
144+When a cloud language pack is loaded, its strings take precedence over the bundled ones,
145+and those packs use Telegram's own name. Other bundled strings that name the upstream app
146+(for example "Fork Client Settings") are not changed.
147+
148+## Icons
149+
150+The default launcher entry is the `org.telegram.messenger.DefaultIcon` activity-alias in
151+`TMessagesProj/src/main/AndroidManifest.xml`, which uses `@mipmap/icon_01_launcher` and
152+`icon_01_launcher_round`; the application icon is `@mipmap/ic_launcher` and
153+`ic_launcher_round`. The overlay replaces these, `icon_01_launcher_adaptive` and
154+`icon_01_launcher_sa`, the picker previews `icon_01_foreground_sa` and
155+`icon_01_background_sa`, and `ic_launcher_dr` (account, contacts and call icon). Every
156+qualifier folder where the tree defines one of them gets a file: an adaptive icon
157+(background colour, vector foreground, monochrome layer for themed icons) in
158+`mipmap-anydpi-v26`, and a PNG of the same pixel size in each density folder.
159+
160+It also replaces:
161+
162+- the Android 12+ splash-screen icon: `drawable/splash_fork_320` (set in
163+ `values-v31/styles.xml`, Forkgram's fork mark) and `drawable/tg_splash_320` (set in
164+ `values-night/styles.xml`, Telegram's animated plane, used in dark mode) both become a
165+ 320dp vector of the generated icon (background disc, mark in the launcher's
166+ proportions). Nothing refers to them from code, so a plain vector can replace the
167+ animated one.
168+- `drawable/notification` (`drawable-mdpi` to `-xxhdpi`), the small icon of message,
169+ service and migration notifications: a white disc with the generated mark cut out, in
170+ the same pixel sizes.
171+
172+The alternative icons of the in-app picker (Original, Vintage, Aqua, Premium, Turbo, Nox)
173+are Telegram's own logos (Original, Aqua and Nox are Telegram's paper plane). Their
174+resources and activity-aliases stay in the APK, but a one-line `MultiGram:` hook in
175+`AppIconsSelectorCell` asks `Rebrand.filterLauncherIcons`, which keeps only the default
176+(the generated) icon in a rebranded build, so they cannot be picked. A stock build lists
177+all of them as before. The Telegram Premium screen still shows the premium icons as a
178+Premium feature.
179+
180+The generator warns when a manifest's label or launcher icon, or a theme's splash icon,
181+comes from a resource it does not cover, e.g. after Forkgram renames its icons.
182+
183+## Account type (contact sync)
184+
185+Android's `AccountManager` keys the contact-sync account on one string, which has to be
186+the same in three places under `TMessagesProj/src/main`:
187+
188+- `java/org/telegram/messenger/ContactsController.java`: five `"org.telegram.messenger"`
189+ literals (`getAccountsByType(...)`, `new Account(...)`);
190+- `res/xml/auth.xml` and `res/xml/sync_contacts.xml`: `android:accountType="${applicationId}"`.
191+
192+AGP expands `${applicationId}` in manifests only, not in resource files, and nothing in
193+this tree processes those two files, so the APK carries the literal text
194+`${applicationId}` as its account type while the Java code asks for
195+`org.telegram.messenger`. That is how Forkgram ships; the stock build is not changed.
196+
197+A rebranded build sets all three to one value: by default the new base applicationId
198+(without the `.beta` suffix). A Java literal cannot be replaced by a resource overlay, so
199+the generator patches the three files in place and marks each with a
200+`MultiGram rebrand: patched by ...` comment. It keeps the originals under
201+`generated/backup/`, always patches from the original (so a new seed works), and `--clean`
202+puts them back byte for byte. `git status` shows the three files as modified while the
203+rebrand is active; do not commit them. The guards:
204+
205+- If a patched file changes after it was patched, `--clean` refuses to overwrite it, and a
206+ new run refuses to take it as the original; both say which file.
207+- If the backup is gone (for example after `rm -rf generated` or `git clean -X`), neither
208+ a new run nor `--clean` can restore the files: both refuse and tell you to run
209+ `git checkout -- <files>` first. `--clean` never reports success while a marked file is
210+ left.
211+- Gradle refuses to build a tree whose three files disagree with `rebrand.properties`,
212+ and, without an active rebrand, a tree that still has a marked file.
213+
214+The account's "Account settings" entry in the system settings (`android:accountPreferences`
215+in `auth.xml`, i.e. `xml/auth_menu.xml`) opens `LaunchActivity` in
216+`android:targetPackage="org.telegram.messenger"`, another app. Stock Forkgram never shows
217+it, because its account cannot be created. A rebranded build's account works, so
218+`rebrand.gradle` generates, per variant, an `auth_menu.xml` that names the variant's own
219+applicationId (`<id>.beta`, `<id>.web`, `<id>.test.beta`, ...).
220+
221+Two builds of one identity with different suffixes (e.g. `.beta` and `.web`) share the
222+account type, so only one of them can hold the account on a device.
223+
224+The contact rows the app writes use the MIME types
225+`vnd.android.cursor.item/vnd.org.telegram.messenger.android.profile` / `.call` /
226+`.call.video` (`xml/contacts.xml`, `ContactsController`, `LaunchActivity`'s intent
227+filters), which Telegram and every fork share. They are left as they are, so with several
228+Telegram-family apps installed, tapping a synced contact row may show a chooser.
229+
230+## google-services.json
231+
232+This tree does not use it. `TMessagesProj_App/build.gradle` does not apply the
233+`com.google.gms.google-services` plugin, the root `build.gradle` does not put it on the
234+classpath, and Firebase is commented out (push goes through UnifiedPush). The
235+`google-services.json` files in the tree are DrKLO's and list `org.telegram.messenger`,
236+`.beta` and `.web`; nothing reads them. The generator leaves them alone and warns if the
237+plugin comes back: then the file must list the applicationId of every built variant
238+(`<id>`, `<id>.beta`, `<id>.web`, ...) from your own Firebase project.
239+
240+## Telegram API credentials and other keys
241+
242+Each distributor needs their own `api_id` and `api_hash` from https://my.telegram.org
243+(API development tools). The builds you make may share yours, but never ship someone
244+else's: not Telegram's, Forkgram's or another fork's. That is what Telegram's API terms
245+require.
246+
247+This tree reads them as Gradle properties `APP_ID` and `APP_HASH`: `TMessagesProj/build.gradle`
248+turns them into `BuildConfig.APP_ID` / `APP_HASH`, and `BuildVars` uses those. The
249+checked-in `gradle.properties` has `APP_ID=0` and `APP_HASH=0`, which cannot log in. Pass
250+yours with `-PAPP_ID=... -PAPP_HASH=...` or put them in `~/.gradle/gradle.properties`
251+(`local.properties` is not read for these two).
252+
253+The repository's CI reads them from the repository secrets `MULTIGRAM_APP_ID` and
254+`MULTIGRAM_APP_HASH` and appends them to `gradle.properties` for the APK build. CI does
255+not run the generator, so a CI APK has the stock identity (Forkgram's name, icons and
256+package) unless the ref it builds contains the rebrand output, and it is signed with the
257+in-tree `TMessagesProj/config/release.keystore`: such APKs are for testing only. Never
258+commit a keystore or `rebrand.properties` with real passwords.
259+
260+Other values that are not yours to reuse:
261+
262+- Forkgram's updater (`org.telegram.messenger.forkgram.AppUpdater`) asks the Telegram
263+ channel `UPDATE_CHANNEL_USERNAME`, then the GitHub releases of `USER_REPO`, and titles
264+ its dialog "The latest Forkgram version". Point both at your own channel and repository,
265+ or leave the checked-in inert values (`a` and `0`); never Forkgram's, or the app would
266+ offer Forkgram's APKs.
267+- `BuildVars` carries Telegram's `SAFETYNET_KEY` and `GOOGLE_AUTH_CLIENT_ID` (used by the
268+ login screen), which belong to Telegram's own app, and `SUPPORTS_PASSKEYS`, which the
269+ file says only works for the official app ids.
270+
271+## Signing and updates
272+
273+Android only installs an update that is signed with the same key, so the key is the
274+app's identity on a device.
275+
276+- One-off builds: the default. Each run makes a new key in `generated/rebrand.keystore`,
277+ and `--clean` deletes it.
278+- Updatable builds: create the key once with `--keystore PATH` outside the repository.
279+ The first run creates `PATH` and `PATH.properties` (alias and password, mode 600); every
280+ later run with the same `--keystore PATH` reuses them, and `--clean` leaves them alone.
281+ Back both files up: without them no update can ever be shipped to existing installs. An
282+ update also needs the same identity (the same `--seed`, or the same pinned
283+ `--application-id` and `--account-type`) and a higher `--version-code` than the
284+ installed build (or a tree with a higher version).
285+- Your own key: run with `--no-keystore` and give the module's usual properties:
286+ `RELEASE_KEYSTORE_FILE`, `RELEASE_STORE_PASSWORD`, `RELEASE_KEY_ALIAS`,
287+ `RELEASE_KEY_PASSWORD`, as `-P` options or in `~/.gradle/gradle.properties`
288+ (`gradle.properties` in the tree already sets the last three, so `local.properties`
289+ cannot override them). Without `RELEASE_KEYSTORE_FILE` the module signs with
290+ `TMessagesProj/config/test.keystore`, whose password is in `TMessagesProj_App/build.gradle`:
291+ anyone with the source can sign an "update" that installs over such an APK and reads its
292+ data. Never distribute an APK signed with an in-tree key; Gradle prints a warning then.
293+
294+If `rebrand.properties` names a keystore (`REBRAND_KEYSTORE`) that is not on disk, for
295+example an absolute `--keystore` path on another machine, the build stops instead of
296+falling back to the module's key. For a compile-only check without the key (as in CI),
297+add `REBRAND_ALLOW_STOCK_SIGNING=true` to `rebrand.properties`, or remove the four
298+`REBRAND_KEY*`/`REBRAND_STORE*` lines.
299+
300+## What stays recognisable
301+
302+This changes the static identity only. Things that still show the app's origin, because
303+changing them would break it or needs code changes:
304+
305+- the MTProto network traffic and Telegram's API endpoints and deep-link hosts (`t.me`,
306+ `telegram.me`, `telegram.dog`, `tg:`);
307+- class and component names kept by `TMessagesProj/proguard-rules.pro`
308+ (`org.telegram.messenger.*` and others), the `org.telegram.messenger.regular` namespace,
309+ and the activity-alias names (`org.telegram.messenger.DefaultIcon`, ...);
310+- the native library `libtmessages.49.so` (CMake target `tmessages.49`);
311+- in-app texts that mention Telegram or Fork Client, and the cloud language packs;
312+- Telegram's wordmark (`drawable/telegram_logo_2`), which the chat list's stories header
313+ shows when it is collapsed, and the welcome screens' animation and texts;
314+- the public media folders `Telegram`, `Telegram Images`, `Telegram Video` and so on
315+ (`ImageLoader`);
316+- the contact-row MIME types (see "Account type");
317+- Forkgram's updater and the keys in `BuildVars` (see "Telegram API credentials and other
318+ keys");
319+- the alternative launcher icons' resources and activity-aliases (hidden from the picker,
320+ see "Icons").
321+
322+## Checking it
323+
324+```bash
325+python3 multigram/rebrand/selftest.py
326+```
327+
328+runs the generator on a throwaway copy of the tree with several seeds and checks the
329+output (XML parses, PNG chunks and sizes, splash and notification icons, the rebrand flag,
330+the keystore opens, the account type and the patch marker in all three files), that each
331+seed repeats (also through `REBRAND_RERUN`) and seeds differ, that git sees only the three
332+patched files, the name and namespace guards, `--keystore` reuse, the guards against
333+edited files and a lost backup, and that `--clean` leaves the tree byte-identical. Run it
334+after moving the stack onto a new Forkgram release, and read the generator's warnings.
335+Whether contact sync and the account settings entry work can only be checked on a device.
multigram/rebrand/generate_rebrand.pynew file+1162
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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())
multigram/rebrand/rebrand.gradlenew file+217
@@ -0,0 +1,217 @@
1+// MultiGram build-time rebrand (see multigram/rebrand/README.md).
2+//
3+// Applied at the end of TMessagesProj_App/build.gradle, after its android {} block, so every
4+// value set here replaces the one the module set before. Without
5+// multigram/rebrand/rebrand.properties (written by generate_rebrand.py) this script only
6+// checks that no rebrand patch is left in the source tree, and the module keeps its own
7+// identity.
8+
9+def rebrandDir = new File(rootDir, "multigram/rebrand")
10+def rebrandFile = new File(rebrandDir, "rebrand.properties")
11+def rebrandProps = new Properties()
12+if (rebrandFile.isFile()) {
13+ rebrandFile.withInputStream { rebrandProps.load(it) }
14+}
15+def rebrandActive = rebrandProps.getProperty("REBRAND_ACTIVE") == "true"
16+
17+// generate_rebrand.py patches the account type into these three files and marks each of
18+// them with this text (PATCH_MARKER there).
19+def patchMarker = "MultiGram rebrand: patched by multigram/rebrand/generate_rebrand.py"
20+def libMain = new File(rootDir, "TMessagesProj/src/main")
21+def contactsFile = new File(libMain, "java/org/telegram/messenger/ContactsController.java")
22+def accountFiles = [contactsFile, new File(libMain, "res/xml/auth.xml"), new File(libMain, "res/xml/sync_contacts.xml")]
23+
24+if (!rebrandActive) {
25+ // A stock build must not ship a rebrand's account type, e.g. after rebrand.properties
26+ // was deleted by hand instead of running --clean. The stock tree never has the marker.
27+ def patched = accountFiles.findAll { it.isFile() && it.getText("UTF-8").contains(patchMarker) }
28+ if (patched) {
29+ def names = patched.collect { rootDir.toPath().relativize(it.toPath()).toString() }
30+ throw new GradleException("[rebrand] no active rebrand (multigram/rebrand/rebrand.properties), " +
31+ "but these files still carry a rebrand's account type: ${names.join(", ")}. " +
32+ "Run python3 multigram/rebrand/generate_rebrand.py --clean " +
33+ "(or git checkout -- ${names.join(" ")}) to build the stock identity.")
34+ }
35+} else {
36+ def prop = { String key -> (rebrandProps.getProperty(key) ?: "").trim() }
37+ def fromRoot = { String path -> def f = new File(path); f.isAbsolute() ? f : new File(rootDir, path) }
38+ def androidExt = project.extensions.getByName("android")
39+ def genRes = new File(rebrandDir, "generated/res")
40+ def rerun = "python3 multigram/rebrand/generate_rebrand.py ${prop("REBRAND_RERUN") ?: "--seed " + prop("REBRAND_SEED")}"
41+ def notes = []
42+
43+ // applicationId. The build types still add their applicationIdSuffix (.beta, .web).
44+ // A flavor with an applicationId of its own (forkTest, org.forkclient.messenger.test)
45+ // replaces the default one, so it gets the new id plus its old suffix (<id>.test), also
46+ // with F_DROID=1, where the default id is org.forkgram.messenger.
47+ def appId = prop("REBRAND_APPLICATION_ID")
48+ if (appId) {
49+ def stockId = androidExt.defaultConfig.applicationId
50+ def stockIds = [stockId, "org.forkclient.messenger", "org.forkgram.messenger"].findAll { it }.unique()
51+ androidExt.defaultConfig.applicationId = appId
52+ androidExt.productFlavors.configureEach { flavor ->
53+ def flavorId = flavor.applicationId
54+ def base = flavorId ? stockIds.find { flavorId.startsWith(it + ".") } : null
55+ if (base) {
56+ flavor.applicationId = appId + flavorId.substring(base.length())
57+ } else if (flavorId) {
58+ flavor.applicationId = appId + "." + flavor.name.toLowerCase().replaceAll(/[^a-z0-9_]/, "")
59+ }
60+ }
61+ notes << "applicationId ${stockId} -> ${appId}"
62+ }
63+
64+ // Optional version override. versionCodeOverride in the module multiplies this by 10
65+ // and adds the ABI digit, as it does for the stock code. This is the APK's version, which
66+ // Telegram's servers see; the in-app version string (TMessagesProj's BuildConfig) stays.
67+ def versionName = prop("REBRAND_VERSION_NAME")
68+ if (versionName) {
69+ androidExt.defaultConfig.versionName = versionName
70+ notes << "versionName ${versionName}"
71+ }
72+ def versionCode = prop("REBRAND_VERSION_CODE")
73+ if (versionCode) {
74+ androidExt.defaultConfig.versionCode = versionCode.toInteger()
75+ notes << "versionCode ${versionCode}"
76+ }
77+
78+ // Resource overlay: app name, launcher, splash and notification icons, and the flag that
79+ // org.telegram.messenger.multigram.Rebrand reads. Build-type resources win over the main
80+ // and flavor source sets and over the TMessagesProj library, so the generated files
81+ // replace the stock ones without duplicate errors.
82+ if (genRes.isDirectory()) {
83+ androidExt.buildTypes.configureEach { bt ->
84+ androidExt.sourceSets.maybeCreate(bt.name).res.srcDir(genRes)
85+ }
86+ // In-app texts come from assets that TelegramStringsTask (buildSrc) builds out of the
87+ // strings.xml files, where a later file wins, so the generated names must be the last
88+ // input. The build-app plugin registers one such task per variant in onVariants, which
89+ // runs in AGP's own afterEvaluate (registered before this script). named().configure
90+ // runs after the plugin's own configuration of the task; configureEach would run
91+ // before it and let the stock names win.
92+ def defaultNames = new File(genRes, "values/rebrand_strings.xml")
93+ def localizedNames = project.fileTree(genRes) { include "values-*/rebrand_strings.xml" }
94+ project.afterEvaluate {
95+ def stringTasks = project.tasks.names.findAll { it ==~ /generate\w+TelegramStrings/ }
96+ stringTasks.each { taskName ->
97+ project.tasks.named(taskName).configure { task ->
98+ if (task.getClass().name.startsWith("org.telegram.tasks.TelegramStringsTask")) {
99+ task.stringsXml.from(defaultNames)
100+ task.localizationFiles.from(localizedNames)
101+ }
102+ }
103+ }
104+ if (stringTasks) {
105+ logger.lifecycle("[rebrand] ${project.path}: app name added to ${stringTasks.size()} TelegramStrings task(s)")
106+ } else {
107+ logger.warn("[rebrand] ${project.path}: no generate*TelegramStrings task found, " +
108+ "the bundled in-app strings keep the stock app name")
109+ }
110+ }
111+ notes << "app name '${prop("REBRAND_APP_NAME")}' and icons from ${genRes}"
112+ }
113+
114+ // Signing: every signing config uses the rebrand key. A missing keystore stops the build:
115+ // the module's fallback is TMessagesProj/config/test.keystore, a public key, and an APK
116+ // of this identity signed with it could be "updated" by anyone who has the source.
117+ def keystore = prop("REBRAND_KEYSTORE")
118+ if (keystore) {
119+ def keystoreFile = fromRoot(keystore)
120+ if (keystoreFile.isFile()) {
121+ def alias = prop("REBRAND_KEY_ALIAS")
122+ def storePassword = prop("REBRAND_STORE_PASSWORD")
123+ def keyPassword = prop("REBRAND_KEY_PASSWORD")
124+ androidExt.signingConfigs.configureEach { sc ->
125+ sc.storeFile = keystoreFile
126+ sc.storePassword = storePassword
127+ sc.keyAlias = alias
128+ sc.keyPassword = keyPassword
129+ }
130+ notes << "signing key ${keystoreFile}"
131+ } else if (prop("REBRAND_ALLOW_STOCK_SIGNING") == "true") {
132+ logger.warn("[rebrand] ${project.path}: keystore ${keystoreFile} not found; REBRAND_ALLOW_STOCK_SIGNING=true, " +
133+ "so the module's own signing config stays. Never distribute this APK.")
134+ } else {
135+ throw new GradleException("[rebrand] signing keystore ${keystoreFile} (REBRAND_KEYSTORE in " +
136+ "multigram/rebrand/rebrand.properties) not found. Put the keystore back, or run " +
137+ "${rerun} (without --keystore that makes a new key, so installed builds cannot be " +
138+ "updated). For a compile-only check, set REBRAND_ALLOW_STOCK_SIGNING=true.")
139+ }
140+ } else {
141+ def publicKeys = androidExt.signingConfigs.findAll { sc ->
142+ sc.storeFile != null && sc.storeFile.isFile() && sc.storeFile.canonicalPath.startsWith(rootDir.canonicalPath + File.separator)
143+ }.collect { it.storeFile.name }.unique()
144+ if (publicKeys) {
145+ logger.warn("[rebrand] ${project.path}: no rebrand key (--no-keystore); signing with ${publicKeys.join(", ")} " +
146+ "from the source tree, which anyone can use. Never distribute this APK; " +
147+ "set RELEASE_KEYSTORE_FILE to your own key.")
148+ }
149+ }
150+
151+ // The account type is patched into three source files by generate_rebrand.py. They must
152+ // agree with each other, or contact sync breaks: refuse to build a half-patched tree.
153+ def accountType = prop("REBRAND_ACCOUNT_TYPE")
154+ if (accountType) {
155+ def expected = [
156+ (accountFiles[0]): "\"${accountType}\"",
157+ (accountFiles[1]): "android:accountType=\"${accountType}\"",
158+ (accountFiles[2]): "android:accountType=\"${accountType}\""
159+ ]
160+ def stale = expected.findAll { f, text -> !f.isFile() || !f.getText("UTF-8").contains(text.toString()) }
161+ if (!stale && contactsFile.getText("UTF-8").contains("\"org.telegram.messenger\"")) {
162+ stale = [(contactsFile): "no stock literal"]
163+ }
164+ if (stale) {
165+ def names = stale.keySet()*.name.join(", ")
166+ throw new GradleException("[rebrand] account type ${accountType} is not in place in ${names}. " +
167+ "Run ${rerun} to patch it again (without --keystore that also makes a new signing " +
168+ "key), or python3 multigram/rebrand/generate_rebrand.py --clean.")
169+ }
170+ notes << "account type ${accountType}"
171+
172+ // The account's "Account settings" entry (xml/auth_menu.xml in TMessagesProj) opens
173+ // LaunchActivity in android:targetPackage, which the library sets to
174+ // org.telegram.messenger, i.e. another app. Each variant installs under its own id
175+ // (.beta, .web, .test), so a generated copy per variant names that id.
176+ def authMenu = new File(libMain, "res/xml/auth_menu.xml")
177+ if (authMenu.isFile()) {
178+ def components = project.extensions.getByName("androidComponents")
179+ components.onVariants(components.selector().all(), { variant ->
180+ def cap = variant.name.substring(0, 1).toUpperCase() + variant.name.substring(1)
181+ def menuTask = project.tasks.register("generate${cap}RebrandAuthMenu", RebrandAuthMenuTask) { t ->
182+ t.template.set(authMenu)
183+ t.targetPackage.set(variant.applicationId)
184+ t.outputDirectory.set(project.layout.buildDirectory.dir("generated/multigramRebrand/${variant.name}/res"))
185+ }
186+ variant.sources.res.addGeneratedSourceDirectory(menuTask, { t -> t.outputDirectory })
187+ } as Action)
188+ notes << "xml/auth_menu.xml per variant, targetPackage = the variant's applicationId"
189+ }
190+ }
191+
192+ notes.each { logger.lifecycle("[rebrand] ${project.path}: ${it}") }
193+}
194+
195+// Writes the library's xml/auth_menu.xml with android:targetPackage set to one variant's
196+// applicationId, into a generated resource directory of that variant (an app resource, so
197+// it replaces the library's file).
198+abstract class RebrandAuthMenuTask extends DefaultTask {
199+ @InputFile
200+ @PathSensitive(PathSensitivity.NONE)
201+ abstract RegularFileProperty getTemplate()
202+
203+ @Input
204+ abstract Property<String> getTargetPackage()
205+
206+ @OutputDirectory
207+ abstract DirectoryProperty getOutputDirectory()
208+
209+ @TaskAction
210+ void generate() {
211+ def text = template.get().asFile.getText("UTF-8")
212+ def replacement = java.util.regex.Matcher.quoteReplacement("android:targetPackage=\"${targetPackage.get()}\"")
213+ def xmlDir = new File(outputDirectory.get().asFile, "xml")
214+ xmlDir.mkdirs()
215+ new File(xmlDir, "auth_menu.xml").setText(text.replaceAll(/android:targetPackage="[^"]*"/, replacement), "UTF-8")
216+ }
217+}
multigram/rebrand/selftest.pynew file+429
@@ -0,0 +1,429 @@
1+#!/usr/bin/env python3
2+# -*- coding: utf-8 -*-
3+"""
4+Self-test for the MultiGram rebrand toolkit. Standard library only (plus git, and keytool
5+for the signing checks).
6+
7+It copies the tracked sources the generator reads (TMessagesProj/src, TMessagesProj/config,
8+TMessagesProj_App) and the toolkit from this working tree into a throwaway git repository,
9+then checks there:
10+
11+ - the outputs are well formed (XML parses, PNG chunks and CRCs, keystore opens)
12+ - the overlay replaces every icon and app-name resource in the same qualifier folders,
13+ including the splash-screen and notification icons, and sets the rebrand flag
14+ - the account type is the same in ContactsController.java, auth.xml and sync_contacts.xml,
15+ and each of them carries the patch marker
16+ - the identity is deterministic per seed (also through REBRAND_RERUN) and differs between
17+ seeds (the key does not repeat)
18+ - git sees only the three patched files; everything generated is ignored
19+ - --no-account-type, --keystore reuse, the name and namespace guards, the conflict guard
20+ and the guards against a lost backup behave
21+ - --clean leaves the tree byte-identical to the commit, with nothing left behind
22+
23+Run it from anywhere: python3 multigram/rebrand/selftest.py [--keep]
24+"""
25+
26+import argparse
27+import hashlib
28+import re
29+import shlex
30+import shutil
31+import struct
32+import subprocess
33+import sys
34+import tempfile
35+import xml.etree.ElementTree as ET
36+import zlib
37+from pathlib import Path
38+
39+sys.dont_write_bytecode = True # importing the generator must not leave __pycache__ behind
40+
41+HERE = Path(__file__).resolve().parent
42+REPO = HERE.parent.parent
43+TOOL_FILES = ["generate_rebrand.py", "rebrand.gradle", ".gitignore", "README.md", "selftest.py"]
44+COPY_PATHS = ["TMessagesProj/src", "TMessagesProj/config", "TMessagesProj_App", ".gitignore"]
45+PATCHED = ["TMessagesProj/src/main/java/org/telegram/messenger/ContactsController.java",
46+ "TMessagesProj/src/main/res/xml/auth.xml",
47+ "TMessagesProj/src/main/res/xml/sync_contacts.xml"]
48+ANDROID = "{http://schemas.android.com/apk/res/android}"
49+
50+failures = []
51+
52+
53+def check(ok, what):
54+ print("%s %s" % ("ok " if ok else "FAIL", what))
55+ if not ok:
56+ failures.append(what)
57+ return ok
58+
59+
60+def git(tree, *args):
61+ return subprocess.run(["git", "-C", str(tree)] + list(args), capture_output=True,
62+ text=True, check=True).stdout
63+
64+
65+def build_tree(tmp):
66+ tree = tmp / "tree"
67+ files = subprocess.run(["git", "-C", str(REPO), "ls-files", "-z", "--"] + COPY_PATHS,
68+ capture_output=True, check=True).stdout.split(b"\0")
69+ for f in files:
70+ if not f:
71+ continue
72+ src = REPO / f.decode()
73+ if src.is_file():
74+ dst = tree / f.decode()
75+ dst.parent.mkdir(parents=True, exist_ok=True)
76+ shutil.copy2(src, dst)
77+ for name in TOOL_FILES:
78+ dst = tree / "multigram/rebrand" / name
79+ dst.parent.mkdir(parents=True, exist_ok=True)
80+ if (HERE / name).is_file():
81+ shutil.copy2(HERE / name, dst)
82+ subprocess.run(["git", "init", "-q", str(tree)], check=True)
83+ git(tree, "add", "-A")
84+ git(tree, "-c", "user.name=selftest", "-c", "user.email=selftest@invalid",
85+ "-c", "commit.gpgsign=false", "commit", "-q", "-m", "baseline")
86+ return tree
87+
88+
89+def run(tree, *args):
90+ p = subprocess.run([sys.executable, str(tree / "multigram/rebrand/generate_rebrand.py")]
91+ + list(args), capture_output=True, text=True)
92+ return p.returncode, p.stdout + p.stderr
93+
94+
95+_tool = {}
96+
97+
98+def tool(tree):
99+ """The generator module of the throwaway tree (for its constants and helpers)."""
100+ if "mod" not in _tool:
101+ import importlib.util
102+ path = tree / "multigram/rebrand/generate_rebrand.py"
103+ spec = importlib.util.spec_from_file_location("rebrand_under_test", path)
104+ mod = importlib.util.module_from_spec(spec)
105+ spec.loader.exec_module(mod)
106+ _tool["mod"] = mod
107+ return _tool["mod"]
108+
109+
110+def props(tree):
111+ return tool(tree).read_properties(tree / "multigram/rebrand/rebrand.properties")
112+
113+
114+def png_ok(path):
115+ b = path.read_bytes()
116+ if b[:8] != b"\x89PNG\r\n\x1a\n":
117+ return False, None
118+ i, idat, ihdr, last = 8, b"", None, None
119+ while i + 8 <= len(b) and last != b"IEND": # (some upstream PNGs have bytes after IEND)
120+ ln = struct.unpack(">I", b[i:i + 4])[0]
121+ typ, data = b[i + 4:i + 8], b[i + 8:i + 8 + ln]
122+ crc = struct.unpack(">I", b[i + 8 + ln:i + 12 + ln])[0]
123+ if zlib.crc32(typ + data) & 0xffffffff != crc:
124+ return False, None
125+ if typ == b"IHDR":
126+ ihdr = struct.unpack(">IIBBBBB", data)
127+ elif typ == b"IDAT":
128+ idat += data
129+ last = typ
130+ i += 12 + ln
131+ if ihdr is None or last != b"IEND":
132+ return False, None
133+ w, h = ihdr[0], ihdr[1]
134+ raw = zlib.decompress(idat)
135+ rows_ok = len(raw) == h * (1 + 4 * w) and all(raw[y * (1 + 4 * w)] <= 4 for y in range(h))
136+ return rows_ok and ihdr[2:] == (8, 6, 0, 0, 0), (w, h)
137+
138+
139+def png_chunks(path, typ):
140+ b = path.read_bytes()
141+ i = 8
142+ while i + 8 <= len(b):
143+ ln = struct.unpack(">I", b[i:i + 4])[0]
144+ if b[i + 4:i + 8] == typ:
145+ yield b[i + 8:i + 8 + ln]
146+ i += 12 + ln
147+
148+
149+def upstream_icons(tree, targets):
150+ found = {}
151+ for g in ("TMessagesProj/src/*/res", "TMessagesProj_App/src/*/res"):
152+ for root in tree.glob(g):
153+ for d in root.iterdir():
154+ if not d.is_dir():
155+ continue
156+ for f in d.iterdir():
157+ name, _, ext = f.name.partition(".")
158+ if (d.name.split("-", 1)[0], name) in targets:
159+ size = png_ok(f)[1] if ext == "png" else None
160+ found[(d.name, name)] = (ext, size)
161+ return found
162+
163+
164+def snapshot(tree):
165+ """Hashes of everything that makes up the identity (not the signing key)."""
166+ h = {}
167+ gen = tree / "multigram/rebrand/generated/res"
168+ for f in sorted(gen.rglob("*")):
169+ if f.is_file():
170+ h[f.relative_to(gen).as_posix()] = hashlib.sha256(f.read_bytes()).hexdigest()
171+ for rel in PATCHED:
172+ h[rel] = hashlib.sha256((tree / rel).read_bytes()).hexdigest()
173+ p = {k: v for k, v in props(tree).items() if not k.startswith(("REBRAND_KEY", "REBRAND_STORE"))}
174+ h["properties"] = repr(sorted(p.items()))
175+ return h
176+
177+
178+def fingerprint(path, pw, alias):
179+ out = subprocess.run(["keytool", "-list", "-v", "-keystore", str(path), "-storepass", pw,
180+ "-alias", alias], capture_output=True, text=True)
181+ m = re.search(r"SHA256:\s*([0-9A-F:]+)", out.stdout)
182+ ok = out.returncode == 0 and "PrivateKeyEntry" in out.stdout and m is not None
183+ return ok, (m.group(1) if m else None)
184+
185+
186+def status(tree, ignored=False):
187+ args = ["status", "--porcelain", "--untracked-files=all"] + (["--ignored"] if ignored else [])
188+ return sorted(git(tree, *args).splitlines())
189+
190+
191+def check_active(tree, label):
192+ p = props(tree)
193+ gen = tree / "multigram/rebrand/generated/res"
194+ app_id, name, acct = p.get("REBRAND_APPLICATION_ID"), p.get("REBRAND_APP_NAME"), p.get("REBRAND_ACCOUNT_TYPE")
195+ check(p.get("REBRAND_ACTIVE") == "true" and app_id and name, "%s: properties written" % label)
196+ check(bool(re.match(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$", app_id or "")),
197+ "%s: applicationId %s is a valid package" % (label, app_id))
198+ xml_ok = True
199+ for f in gen.rglob("*.xml"):
200+ try:
201+ ET.parse(f)
202+ except ET.ParseError:
203+ xml_ok = False
204+ print(" bad XML: %s" % f)
205+ check(xml_ok, "%s: every generated XML parses" % label)
206+ pngs = list(gen.rglob("*.png"))
207+ bad = [f for f in pngs if not png_ok(f)[0]]
208+ check(pngs and not bad, "%s: %d PNGs valid (signature, CRCs, IHDR, IDAT)" % (label, len(pngs)))
209+
210+ targets = set(tool(tree).ICON_TARGETS)
211+ names = set(tool(tree).APP_NAME_STRINGS)
212+ up = upstream_icons(tree, targets)
213+ ours = {}
214+ for f in gen.rglob("*"):
215+ if f.is_file() and f.parent.name.split("-", 1)[0] in ("mipmap", "drawable"):
216+ n, _, ext = f.name.partition(".")
217+ if (f.parent.name.split("-", 1)[0], n) in targets:
218+ ours[(f.parent.name, n)] = (ext, png_ok(f)[1] if ext == "png" else None)
219+ check(up and set(up) == set(ours),
220+ "%s: overlay covers all %d upstream icon files in the same folders" % (label, len(up)))
221+ check(all(up[k] == ours.get(k) for k in up), "%s: same file type and pixel size as upstream" % label)
222+ adaptive_ok = True
223+ for (qdir, n), (ext, _) in ours.items():
224+ if ext == "xml" and qdir.startswith("mipmap-anydpi"):
225+ root = ET.parse(gen / qdir / (n + ".xml")).getroot()
226+ refs = [c.get(ANDROID + "drawable") for c in root]
227+ adaptive_ok &= root.tag == "adaptive-icon" and [c.tag for c in root] == [
228+ "background", "foreground", "monochrome"]
229+ adaptive_ok &= refs == ["@color/multigram_rebrand_icon_bg",
230+ "@drawable/multigram_rebrand_icon_fg",
231+ "@drawable/multigram_rebrand_icon_mono"]
232+ colors = ET.parse(gen / "values/rebrand_colors.xml").getroot()
233+ adaptive_ok &= any(c.get("name") == "multigram_rebrand_icon_bg" for c in colors)
234+ adaptive_ok &= (gen / "drawable/multigram_rebrand_icon_fg.xml").is_file()
235+ adaptive_ok &= (gen / "drawable/multigram_rebrand_icon_mono.xml").is_file()
236+ check(adaptive_ok, "%s: adaptive icons point at generated layers that exist" % label)
237+ shape, bg, fg = (p.get("REBRAND_ICON") or "? ? ?").split()
238+ splash = [(q, n) for (q, n), kind in ((k, tool(tree).ICON_TARGETS.get((k[0].split("-")[0], k[1])))
239+ for k in ours) if kind == "splash"]
240+ splash_ok = bool(splash)
241+ for q, n in splash:
242+ root = ET.parse(gen / q / (n + ".xml")).getroot()
243+ colors = [c.get(ANDROID + "fillColor") for c in root.iter("path")]
244+ splash_ok &= root.tag == "vector" and root.get(ANDROID + "viewportWidth") == "320" \
245+ and colors[:1] == [bg] and set(colors[1:]) == {fg}
246+ check(splash_ok, "%s: %d splash icons are vectors in the icon's colours" % (label, len(splash)))
247+ notif = [f for f in gen.rglob("notification.png")]
248+ notif_ok = bool(notif)
249+ for f in notif:
250+ size = png_ok(f)[1][0]
251+ raw = zlib.decompress(b"".join(c for c in png_chunks(f, b"IDAT")))
252+ rows = [raw[y * (1 + 4 * size) + 1:(y + 1) * (1 + 4 * size)] for y in range(size)]
253+ px = [r[i:i + 4] for r in rows for i in range(0, len(r), 4)]
254+ alphas = {q[3] for q in px}
255+ notif_ok &= all(q[:3] == b"\xff\xff\xff" for q in px) and 0 in alphas and 255 in alphas
256+ check(notif_ok, "%s: %d notification icons are white on transparent" % (label, len(notif)))
257+ flags = {e.get("name"): e.text for e in ET.parse(gen / "values/rebrand_flags.xml").getroot()}
258+ check(flags == {"multigram_rebrand_active": "true"}, "%s: overlay sets multigram_rebrand_active" % label)
259+ strings_ok = True
260+ for f in gen.glob("values*/rebrand_strings.xml"):
261+ got = {e.get("name"): e.text for e in ET.parse(f).getroot().iter("string")}
262+ strings_ok &= set(got) == names and all(
263+ v.replace("\\'", "'").replace('\\"', '"') == name for v in got.values())
264+ check(strings_ok and (gen / "values/rebrand_strings.xml").is_file(),
265+ "%s: AppName, AppNameBeta and AppNameFdroid are '%s'" % (label, name))
266+
267+ java = (tree / PATCHED[0]).read_text(encoding="utf-8")
268+ xml_types = [ET.parse(tree / f).getroot().get(ANDROID + "accountType") for f in PATCHED[1:]]
269+ check(acct == app_id, "%s: account type derived from the applicationId" % label)
270+ check(java.count('"%s"' % acct) == 5 and '"org.telegram.messenger"' not in java
271+ and xml_types == [acct, acct],
272+ "%s: account type %s in Java (5x) and both XML files" % (label, acct))
273+ marker = tool(tree).PATCH_MARKER
274+ check(all(marker in (tree / f).read_text(encoding="utf-8") for f in PATCHED),
275+ "%s: the three patched files carry the patch marker" % label)
276+ check(status(tree) == sorted(" M " + f for f in PATCHED),
277+ "%s: git shows only the three patched files" % label)
278+ ign = [l for l in status(tree, True) if l.startswith("!!")]
279+ check("!! multigram/rebrand/rebrand.properties" in ign and all(
280+ l.startswith("!! multigram/rebrand/generated/")
281+ or l == "!! multigram/rebrand/rebrand.properties" for l in ign),
282+ "%s: everything generated is git-ignored" % label)
283+ return p
284+
285+
286+def main():
287+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
288+ ap.add_argument("--keep", action="store_true", help="keep the throwaway tree")
289+ args = ap.parse_args()
290+ if (HERE / "rebrand.properties").exists() or (HERE / "generated").exists():
291+ print("This tree is rebranded; run generate_rebrand.py --clean first.")
292+ return 2
293+ have_keytool = shutil.which("keytool") is not None
294+ tmp = Path(tempfile.mkdtemp(prefix="rebrand-selftest-"))
295+ try:
296+ tree = build_tree(tmp)
297+ print("throwaway tree: %s" % tree)
298+ hook = (tree / "TMessagesProj_App/build.gradle").read_text(encoding="utf-8")
299+ check('apply from: "${rootDir}/multigram/rebrand/rebrand.gradle"' in hook,
300+ "TMessagesProj_App/build.gradle applies rebrand.gradle")
301+ base = hashlib.sha256(b"".join((tree / f).read_bytes() for f in PATCHED)).hexdigest()
302+ ks = [] if have_keytool else ["--no-keystore"]
303+
304+ rc, out = run(tree, "--seed", "selftest-a", *ks)
305+ check(rc == 0, "seed A: generator succeeds")
306+ print("\n".join(" " + l for l in out.splitlines()[:6]))
307+ check("WARNING" not in out, "seed A: no warnings")
308+ pa = check_active(tree, "seed A")
309+ snap_a = snapshot(tree)
310+ fp_a = None
311+ if have_keytool:
312+ ok, fp_a = fingerprint(tree / pa["REBRAND_KEYSTORE"], pa["REBRAND_STORE_PASSWORD"],
313+ pa["REBRAND_KEY_ALIAS"])
314+ check(ok and pa["REBRAND_STORE_PASSWORD"] == pa["REBRAND_KEY_PASSWORD"],
315+ "seed A: keystore opens with keytool and holds the key")
316+
317+ rc, _ = run(tree, "--seed", "selftest-a", *ks)
318+ check(rc == 0 and snapshot(tree) == snap_a, "seed A again: identical identity")
319+ rerun = shlex.split(props(tree).get("REBRAND_RERUN", ""))
320+ rc, _ = run(tree, *rerun)
321+ check(rc == 0 and rerun[:2] == ["--seed", "selftest-a"] and snapshot(tree) == snap_a,
322+ "REBRAND_RERUN (%s) repeats the identity" % " ".join(rerun))
323+ if ks:
324+ run(tree, "--seed", "selftest-a", *ks)
325+ if have_keytool:
326+ p2 = props(tree)
327+ ok, fp2 = fingerprint(tree / p2["REBRAND_KEYSTORE"], p2["REBRAND_STORE_PASSWORD"],
328+ p2["REBRAND_KEY_ALIAS"])
329+ check(ok and fp2 != fp_a, "seed A again: a fresh signing key (not from the seed)")
330+
331+ rc, _ = run(tree, "--seed", "selftest-b", *ks)
332+ check(rc == 0, "seed B: generator succeeds (re-patches from the backup)")
333+ pb = check_active(tree, "seed B")
334+ check(snapshot(tree) != snap_a and pb["REBRAND_APPLICATION_ID"] != pa["REBRAND_APPLICATION_ID"],
335+ "seed B: a different identity (%s, '%s')" % (pb["REBRAND_APPLICATION_ID"],
336+ pb["REBRAND_APP_NAME"]))
337+ java = (tree / PATCHED[0]).read_text(encoding="utf-8")
338+ check('"%s"' % pa["REBRAND_ACCOUNT_TYPE"] not in java, "seed B: nothing of seed A left in the Java file")
339+
340+ rc, _ = run(tree, "--seed", "selftest-b", "--no-account-type", "--no-keystore")
341+ check(rc == 0 and status(tree) == [] and "REBRAND_ACCOUNT_TYPE" not in props(tree),
342+ "--no-account-type: the three files are back to stock")
343+
344+ rc, _ = run(tree, "--app-name", "Telegram Lite", "--no-keystore")
345+ check(rc == 1, "refuses an app name with 'Telegram' in it")
346+ rc, _ = run(tree, "--application-id", "org.telegram.lite", "--no-keystore")
347+ check(rc == 1, "refuses an applicationId in org.telegram")
348+ rc, _ = run(tree, "--application-id", "Not A Package", "--no-keystore")
349+ check(rc == 1, "refuses an invalid applicationId")
350+ rc, _ = run(tree, "--application-id", "com.facebook.orca", "--no-keystore")
351+ check(rc == 1, "refuses an applicationId in another vendor's namespace")
352+ rc, _ = run(tree, "--app-name", "Facebook Lite", "--no-keystore")
353+ check(rc == 1, "refuses another app's name")
354+ odd = "Tom's <Chat> & \"Co\" #1 \u00e9"
355+ rc, _ = run(tree, "--app-name", odd, "--no-keystore", "--seed", "x")
356+ check(rc == 0 and props(tree).get("REBRAND_APP_NAME") == odd,
357+ "escapes an app name with quotes, <, >, &, # and non-ASCII")
358+ if rc == 0:
359+ check_active(tree, "escaped name")
360+ snap = snapshot(tree)
361+ rc, _ = run(tree, *shlex.split(props(tree).get("REBRAND_RERUN", "")))
362+ check(rc == 0 and snapshot(tree) == snap, "REBRAND_RERUN repeats an escaped --app-name")
363+
364+ if have_keytool:
365+ ext = tmp / "keys" / "mine.keystore"
366+ rc, _ = run(tree, "--seed", "selftest-a", "--keystore", str(ext))
367+ p1 = props(tree)
368+ ok1, f1 = fingerprint(ext, p1.get("REBRAND_STORE_PASSWORD", ""), p1.get("REBRAND_KEY_ALIAS", ""))
369+ rc2, _ = run(tree, "--seed", "selftest-b", "--keystore", str(ext))
370+ p2 = props(tree)
371+ ok2, f2 = fingerprint(ext, p2.get("REBRAND_STORE_PASSWORD", ""), p2.get("REBRAND_KEY_ALIAS", ""))
372+ check(rc == 0 and rc2 == 0 and ok1 and ok2 and f1 == f2
373+ and p2.get("REBRAND_KEYSTORE") == str(ext.resolve()),
374+ "--keystore: created once, then reused (same certificate)")
375+ rc, _ = run(tree, "--keystore", str(tree / "inside.keystore"))
376+ check(rc == 1, "--keystore refuses a path inside the repository")
377+
378+ run(tree, "--seed", "selftest-a", "--no-keystore")
379+ auth = tree / PATCHED[1]
380+ auth.write_bytes(auth.read_bytes() + b"\n")
381+ rc, _ = run(tree, "--clean")
382+ check(rc == 1 and (tree / "multigram/rebrand/rebrand.properties").exists(),
383+ "--clean refuses to overwrite a patched file that was edited since")
384+ rc, _ = run(tree, "--seed", "selftest-b", "--no-keystore")
385+ check(rc == 1 and props(tree).get("REBRAND_SEED") == "selftest-a",
386+ "a new seed refuses to take an edited patched file as the original")
387+ git(tree, "checkout", "--", PATCHED[1])
388+ rc, _ = run(tree, "--seed", "selftest-b", "--no-keystore")
389+ check(rc == 0 and ET.parse(auth).getroot().get(ANDROID + "accountType")
390+ == props(tree).get("REBRAND_ACCOUNT_TYPE"),
391+ "a patched file restored by git is patched again from the stock original")
392+
393+ # generated/ lost while the three files are still patched
394+ shutil.rmtree(tree / "multigram/rebrand/generated")
395+ rc, out = run(tree, "--seed", "selftest-a", "--no-keystore")
396+ check(rc == 1 and "git checkout" in out,
397+ "a new run refuses files patched by a rebrand whose backup is gone")
398+ rc, out = run(tree, "--clean")
399+ check(rc == 1 and "git checkout -- " in out and status(tree) == sorted(" M " + f for f in PATCHED)
400+ and (tree / "multigram/rebrand/rebrand.properties").exists(),
401+ "--clean without a backup refuses and names git checkout, changing nothing")
402+ git(tree, "checkout", "--", *PATCHED)
403+ rc, _ = run(tree, "--clean")
404+ check(rc == 0 and status(tree, True) == [], "--clean succeeds once git restored the files")
405+ run(tree, "--seed", "selftest-a", "--no-keystore")
406+
407+ rc, _ = run(tree, "--clean")
408+ after = hashlib.sha256(b"".join((tree / f).read_bytes() for f in PATCHED)).hexdigest()
409+ check(rc == 0 and status(tree, True) == [] and after == base,
410+ "--clean: git status clean (incl. ignored files), files byte-identical")
411+ check(subprocess.run(["git", "-C", str(tree), "diff", "--quiet", "HEAD"]).returncode == 0,
412+ "--clean: no difference to the commit")
413+ if have_keytool:
414+ check((tmp / "keys" / "mine.keystore").is_file(), "--clean keeps a --keystore key")
415+ rc, _ = run(tree, "--clean")
416+ check(rc == 0 and status(tree, True) == [], "--clean twice is harmless")
417+ finally:
418+ if args.keep:
419+ print("kept %s" % tmp)
420+ else:
421+ shutil.rmtree(tmp, ignore_errors=True)
422+ if not have_keytool:
423+ print("note: keytool not found, signing checks skipped")
424+ print("\n%s: %d failure(s)" % ("FAILED" if failures else "PASSED", len(failures)))
425+ return 1 if failures else 0
426+
427+
428+if __name__ == "__main__":
429+ sys.exit(main())