on main help sign in
Add the rebrand toolkit

One command gives a build its own identity: applicationId, app name, launcher, splash-screen and notification icons, signing key and a matching Android account type; --clean puts the stock identity back byte for byte. The identity is deterministic per --seed, the signing key is not. Ported from the toolkit written for plain DrKLO Telegram and adapted to this tree: only TMessagesProj_App is hooked (one apply line at the end of its build.gradle), the overlay replaces Forkgram's own launcher icons (icon_01_* aliases and ic_launcher), its splash and notification icons and all three app-name strings, the generated names also feed TelegramStringsTask for the bundled in-app strings, the flavor with its own applicationId (forkTest) is renamed too, and google-services.json is left alone because this tree does not apply the google-services plugin. The account type is patched in ContactsController.java, auth.xml and sync_contacts.xml together, with backups, a marker in each patched file and guards against edited files and lost backups. Gradle refuses to build a half-patched tree, and a stock build that still carries a patch. Each variant gets an auth_menu.xml that points at its own package. A missing rebrand keystore stops the build instead of falling back to the module's public test key. Small hooks in DialogsActivity, ForkSettingsActivity and AppIconsSelectorCell ask org.telegram.messenger.multigram.Rebrand, which reads a resource flag set by the overlay: a rebranded build uses its own name as the default chat-list title (Forkgram hard-codes "Fork Client") and offers only its own launcher icon, since the alternatives are Forkgram's and Telegram's logos. Stock builds behave as before. The generator refuses the Telegram name and namespace, Forkgram's names and some other well-known apps' names and namespaces; selftest.py checks the whole flow on a throwaway copy of the tree. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mVy1fu8zBn38wHgJqmLNz

commita1f0a5d9e37b6788fbcead5b37909617309f098b authorClaude <noreply@anthropic.com> dateSun 27 Sep 2026, 01:04 (2 weeks ago) parent4a3faeb
11 files changed+2229−3
TMessagesProj/src/main/java/org/telegram/messenger/multigram/Rebrand.java+69 TMessagesProj/src/main/java/org/telegram/ui/Cells/AppIconsSelectorCell.java+1 TMessagesProj/src/main/java/org/telegram/ui/DialogsActivity.java+2−1 TMessagesProj/src/main/java/org/telegram/ui/ForkSettingsActivity.java+3−2 TMessagesProj/src/main/res/values/multigram_rebrand.xml+5 TMessagesProj_App/build.gradle+3 multigram/rebrand/.gitignore+3 multigram/rebrand/README.md+335 multigram/rebrand/generate_rebrand.py+1162 multigram/rebrand/rebrand.gradle+217 multigram/rebrand/selftest.py+429
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
151151 private void updateIconsVisibility() {
152152 availableIcons.clear();
153153 availableIcons.addAll(Arrays.asList(LauncherIconController.LauncherIcon.values()));
154+ org.telegram.messenger.multigram.Rebrand.filterLauncherIcons(availableIcons); // MultiGram: a rebranded build offers only its own icon
154155 if (MessagesController.getInstance(currentAccount).premiumFeaturesBlocked()) {
155156 for (int i = 0; i < availableIcons.size(); i++) {
156157 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.
35103510 SpannableStringBuilder ssb = new SpannableStringBuilder(getString(R.string.AppName));
35113511 ssb.setSpan(new ImageSpan(logoDrawable), 0, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
35123512 actionBar.setTitle(ssb, statusDrawable);
3513− actionBar.setTitle(MessagesController.getGlobalMainSettings().getString("forkCustomTitle", "Fork Client"));
3513+ // MultiGram: a rebranded build's default title is its own app name.
3514+ actionBar.setTitle(MessagesController.getGlobalMainSettings().getString("forkCustomTitle", org.telegram.messenger.multigram.Rebrand.defaultTitle("Fork Client")));
35143515 actionBar.setTitleLongClickListener(v -> {
35153516 boolean mainTabsHidden = !UserConfig.getInstance(currentAccount).getMainTabsHiddenFork();
35163517 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 {
555555 items.add(UItem.asButtonCheck(ID_HIDE_BOTTOM_BUTTON, LocaleController.getString(R.string.HideBottomButton), LocaleController.getString(R.string.HideBottomButtonInfo))
556556 .setChecked(pref("hideBottomButton", false)).setMultiline(true));
557557 }
558− items.add(UItem.asSettingsCell(ID_CUSTOM_TITLE, LocaleController.getString(R.string.EditAdminRank), prefs().getString("forkCustomTitle", "Fork Client")));
558+ // MultiGram: a rebranded build's default title is its own app name.
559+ items.add(UItem.asSettingsCell(ID_CUSTOM_TITLE, LocaleController.getString(R.string.EditAdminRank), prefs().getString("forkCustomTitle", org.telegram.messenger.multigram.Rebrand.defaultTitle("Fork Client"))));
559560 items.add(UItem.asShadow(null));
560561
561562 items.add(UItem.asHeader(LocaleController.getString(R.string.AvatarShape)));
@@ -1169,7 +1170,7 @@ public class ForkSettingsActivity extends BaseFragment {
11691170 }
11701171
11711172 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
11731174 org.telegram.messenger.forkgram.ForkDialogs.createFieldAlert(
11741175 getContext(),
11751176 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 {
338338 checkReleaseBuilds false
339339 }
340340}
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())