on main help sign in
9 files changed+2549−3719
since splitsnapshots
unifiedsplit
.github/workflows/build.yml+137 .github/workflows/release.yml+242 .github/workflows/sync-forkgram.yml+177 PATCH.md−3719 README.md+140 scripts/export-patches.sh+86 scripts/selftest-sync.sh+646 scripts/sync-forkgram.sh+848 scripts/sync-report.py+273
.github/workflows/build.ymlnew file+137
@@ -0,0 +1,137 @@
1+name: Build MultiGram
2+run-name: Build ${{ inputs.ref }}${{ inputs.apk && ' (APK)' || '' }}
3+
4+on:
5+ workflow_dispatch:
6+ inputs:
7+ ref:
8+ description: Branch, tag or commit to build
9+ default: multigram
10+ required: true
11+ apk:
12+ description: Also build a full release APK (slow, compiles the native code)
13+ type: boolean
14+ default: false
15+ workflow_call:
16+ inputs:
17+ ref:
18+ type: string
19+ required: true
20+ apk:
21+ type: boolean
22+ default: false
23+ build_number:
24+ description: MultiGram build number (0 to 9), the fourth part of the version (release.yml sets it)
25+ type: string
26+ default: '0'
27+
28+permissions:
29+ contents: read
30+
31+jobs:
32+ compile:
33+ name: Compile Java and Kotlin
34+ runs-on: ubuntu-latest
35+ timeout-minutes: 60
36+ steps:
37+ - uses: actions/checkout@v4
38+ with:
39+ ref: ${{ inputs.ref }}
40+ - name: Fetch the submodules the Java build needs
41+ run: git submodule update --init --depth 1 TMessagesProj/lib/jlatexmath TMessagesProj_Modules/media
42+ - uses: actions/setup-java@v4
43+ with:
44+ distribution: temurin
45+ java-version: '17'
46+ - uses: gradle/actions/setup-gradle@v4
47+ - name: Compile
48+ run: |
49+ echo "org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g" >> gradle.properties
50+ # buildNativeDeps compiles ffmpeg, libvpx, dav1d and friends; the Java and Kotlin
51+ # compile does not need them, so skip it here (the apk job builds them).
52+ ./gradlew --no-daemon --stacktrace -x :TMessagesProj:buildNativeDeps \
53+ :TMessagesProj:compileReleaseJavaWithJavac \
54+ :TMessagesProj_App:compileAfatReleaseJavaWithJavac
55+ - name: Run MultiGram tool checks
56+ if: hashFiles('multigram/tools/check.sh') != ''
57+ run: bash multigram/tools/check.sh
58+
59+ apk:
60+ name: Release APK
61+ if: ${{ inputs.apk }}
62+ needs: compile
63+ runs-on: ubuntu-latest
64+ timeout-minutes: 300
65+ steps:
66+ - uses: actions/checkout@v4
67+ with:
68+ ref: ${{ inputs.ref }}
69+ submodules: true
70+ - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
71+ with:
72+ tool-cache: false
73+ android: false
74+ - name: Add swap space
75+ # Runners for private repositories have about 8 GB of memory; the native build and the
76+ # Gradle and Kotlin daemons together can use more, and the runner dies without a log.
77+ run: |
78+ sudo fallocate -l 8G /multigram-swap
79+ sudo chmod 600 /multigram-swap
80+ sudo mkswap /multigram-swap
81+ sudo swapon /multigram-swap
82+ free -h
83+ - uses: actions/setup-java@v4
84+ with:
85+ distribution: temurin
86+ java-version: '17'
87+ - uses: nttld/setup-ndk@v1.4.2
88+ with:
89+ ndk-version: r27c
90+ link-to-sdk: true
91+ - name: Install NDK for the JNI build
92+ run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;27.2.12479018"
93+ - name: Build
94+ env:
95+ APP_ID: ${{ secrets.MULTIGRAM_APP_ID }}
96+ APP_HASH: ${{ secrets.MULTIGRAM_APP_HASH }}
97+ KEYSTORE_BASE64: ${{ secrets.MULTIGRAM_KEYSTORE_BASE64 }}
98+ KEYSTORE_PASSWORD: ${{ secrets.MULTIGRAM_KEYSTORE_PASSWORD }}
99+ KEY_ALIAS: ${{ secrets.MULTIGRAM_KEY_ALIAS }}
100+ KEY_PASSWORD: ${{ secrets.MULTIGRAM_KEY_PASSWORD }}
101+ BUILD_NUMBER: ${{ inputs.build_number }}
102+ run: |
103+ vars=gradle.properties
104+ # Forkgram's versioning: versionName APP_VERSION_NAME.N and versionCode
105+ # (APP_VERSION_CODE * 10 + N) * 10 + the flavor's ABI code, with N = ADDITIONAL_BUILD_NUMBER.
106+ case "${BUILD_NUMBER:-0}" in [0-9]) ;; *) echo "::error::build_number must be one digit"; exit 1;; esac
107+ echo "ADDITIONAL_BUILD_NUMBER=${BUILD_NUMBER:-0}" >> $vars
108+ echo "org.gradle.workers.max=1" >> $vars
109+ # Forkgram asks for an 8 GB heap, sized for the bigger runners of public repositories.
110+ echo "org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8" >> $vars
111+ echo "kotlin.daemon.jvmargs=-Xmx2g" >> $vars
112+ echo "org.gradle.parallel=false" >> $vars
113+ [ -n "$APP_ID" ] && echo "APP_ID=$APP_ID" >> $vars
114+ [ -n "$APP_HASH" ] && echo "APP_HASH=$APP_HASH" >> $vars
115+ signing=()
116+ if [ -n "$KEYSTORE_BASE64" ]; then
117+ # Your own release key, from the MULTIGRAM_KEYSTORE_* secrets; it never touches the repository.
118+ echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
119+ signing=(
120+ "-PRELEASE_KEYSTORE_FILE=$RUNNER_TEMP/release.keystore"
121+ "-PRELEASE_STORE_PASSWORD=$KEYSTORE_PASSWORD"
122+ "-PRELEASE_KEY_ALIAS=$KEY_ALIAS"
123+ "-PRELEASE_KEY_PASSWORD=${KEY_PASSWORD:-$KEYSTORE_PASSWORD}"
124+ )
125+ else
126+ # Without RELEASE_KEYSTORE_FILE the build signs with the public test key in
127+ # TMessagesProj/config/test.keystore: fine for testing, not for an APK you give out.
128+ echo "::warning::No MULTIGRAM_KEYSTORE_BASE64 secret: the APK is signed with the public test key."
129+ fi
130+ sudo apt update
131+ sudo apt -y install ninja-build meson nasm gperf pkg-config unzip
132+ rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
133+ ./gradlew "${signing[@]}" :TMessagesProj_App:assembleAfatRelease
134+ - uses: actions/upload-artifact@v4
135+ with:
136+ name: multigram-apk
137+ path: TMessagesProj_App/build/outputs/apk/afat/release/*.apk
.github/workflows/release.ymlnew file+242
@@ -0,0 +1,242 @@
1+name: Release MultiGram
2+run-name: Release ${{ inputs.ref }}${{ inputs.dry_run && ' (dry run)' || '' }}
3+
4+# Builds the release APK of a MultiGram commit and publishes it as a GitHub Release.
5+#
6+# Version: Forkgram's own scheme, APP_VERSION_NAME.N (for example 12.10.6.1), where APP_VERSION_NAME
7+# comes from gradle.properties of the Forkgram snapshot and N (1 to 9) counts MultiGram releases on
8+# that Forkgram version. The Android versionCode is (APP_VERSION_CODE * 10 + N) * 10 + 9, so every
9+# release installs as an update over the one before, and a new Forkgram version starts again at N = 1.
10+# The tag is the version itself, like Forkgram's tags.
11+
12+on:
13+ workflow_dispatch:
14+ inputs:
15+ ref:
16+ description: Branch, tag or commit to release
17+ default: multigram
18+ required: true
19+ number:
20+ description: MultiGram build number for this Forkgram version (1 to 9). Empty picks the next free one.
21+ required: false
22+ default: ''
23+ prerelease:
24+ description: Mark it as a pre-release (always the case without your own signing key)
25+ type: boolean
26+ default: false
27+ dry_run:
28+ description: Only work out the version and the release notes; build and publish nothing
29+ type: boolean
30+ default: false
31+
32+permissions:
33+ contents: read
34+
35+concurrency:
36+ group: release
37+ cancel-in-progress: false
38+
39+jobs:
40+ check_secrets:
41+ # A job of its own, so that no secret value is registered in the job that writes the release
42+ # notes: the runner drops any job output that contains a secret's value (a key alias like
43+ # "release" would otherwise blank the notes).
44+ name: Check the secrets
45+ runs-on: ubuntu-latest
46+ outputs:
47+ signed: ${{ steps.s.outputs.signed }}
48+ steps:
49+ - name: Check the secrets
50+ id: s
51+ env:
52+ DRY_RUN: ${{ inputs.dry_run }}
53+ APP_ID: ${{ secrets.MULTIGRAM_APP_ID }}
54+ APP_HASH: ${{ secrets.MULTIGRAM_APP_HASH }}
55+ KEYSTORE_BASE64: ${{ secrets.MULTIGRAM_KEYSTORE_BASE64 }}
56+ KEYSTORE_PASSWORD: ${{ secrets.MULTIGRAM_KEYSTORE_PASSWORD }}
57+ KEY_ALIAS: ${{ secrets.MULTIGRAM_KEY_ALIAS }}
58+ run: |
59+ set -euo pipefail
60+ # Stop here, in seconds, rather than after the long build. A dry run only warns.
61+ problem() { if [ "$DRY_RUN" = true ]; then echo "::warning::$1"; else echo "::error::$1"; exit 1; fi; }
62+ if [ -z "$APP_ID" ] || [ -z "$APP_HASH" ]; then
63+ problem "The MULTIGRAM_APP_ID and MULTIGRAM_APP_HASH secrets are not set, so the APK could not log in. Add them (Settings > Secrets and variables > Actions) and run this again."
64+ fi
65+ if [ -n "$KEYSTORE_BASE64" ]; then
66+ if [ -z "$KEYSTORE_PASSWORD" ] || [ -z "$KEY_ALIAS" ]; then
67+ problem "MULTIGRAM_KEYSTORE_BASE64 is set, but MULTIGRAM_KEYSTORE_PASSWORD or MULTIGRAM_KEY_ALIAS is not: signing would fail at the end of the build. Add both."
68+ fi
69+ echo "signed=true" >> "$GITHUB_OUTPUT"
70+ else
71+ echo "::warning::No MULTIGRAM_KEYSTORE_BASE64 secret: the release will be signed with Forkgram's public test key and published as a pre-release."
72+ echo "signed=false" >> "$GITHUB_OUTPUT"
73+ fi
74+
75+ prepare:
76+ name: Work out the version
77+ needs: check_secrets
78+ runs-on: ubuntu-latest
79+ outputs:
80+ sha: ${{ steps.v.outputs.sha }}
81+ number: ${{ steps.v.outputs.number }}
82+ version: ${{ steps.v.outputs.version }}
83+ version_code: ${{ steps.v.outputs.version_code }}
84+ prerelease: ${{ steps.v.outputs.prerelease }}
85+ notes: ${{ steps.v.outputs.notes }}
86+ steps:
87+ - uses: actions/checkout@v4
88+ with:
89+ ref: ${{ inputs.ref }}
90+ fetch-depth: 0
91+ - name: Version, secrets and release notes
92+ id: v
93+ env:
94+ NUMBER: ${{ inputs.number }}
95+ PRERELEASE: ${{ inputs.prerelease }}
96+ DRY_RUN: ${{ inputs.dry_run }}
97+ SIGNED: ${{ needs.check_secrets.outputs.signed }}
98+ run: |
99+ set -euo pipefail
100+ fail() { echo "::error::$1"; exit 1; }
101+ sha=$(git rev-parse HEAD)
102+ name=$(sed -n 's/^APP_VERSION_NAME=//p' gradle.properties | tail -n 1)
103+ code=$(sed -n 's/^APP_VERSION_CODE=//p' gradle.properties | tail -n 1)
104+ [[ "$name" =~ ^[0-9]+(\.[0-9]+)*$ ]] || fail "APP_VERSION_NAME in gradle.properties is '$name', not a version number."
105+ [[ "$code" =~ ^[0-9]+$ ]] || fail "APP_VERSION_CODE in gradle.properties is '$code', not a number."
106+
107+ # The next free N for this Forkgram version, from the tags that already exist.
108+ last=$(git tag -l "$name.*" | sed -n "s/^${name//./\\.}\.\([0-9]\)$/\1/p" | sort -n | tail -n 1)
109+ if [ -n "$NUMBER" ]; then
110+ n=$NUMBER
111+ else
112+ n=$(( ${last:-0} + 1 ))
113+ fi
114+ [[ "$n" =~ ^[1-9]$ ]] || fail "The MultiGram build number must be 1 to 9 (got '$n'). Nine releases on Forkgram $name are the most its version codes allow; wait for the next Forkgram version."
115+ version="$name.$n"
116+ if git rev-parse -q --verify "refs/tags/$version" >/dev/null; then
117+ fail "Tag $version already exists: MultiGram $version is already released. Leave the number empty to take the next free one."
118+ fi
119+ if [ -n "$last" ] && [ "$n" -le "$last" ]; then
120+ echo "::warning::$name.$last is already released, so $version has a lower version code and will not install as an update over it."
121+ fi
122+ version_code=$(( (code * 10 + n) * 10 + 9 ))
123+
124+ # What it is built on, from the snapshot commit the sync made.
125+ snap=$(git log -n 1 --format=%H --grep='^Forkgram-Version: ' HEAD || true)
126+ fg_version=unknown; tg_version=unknown
127+ if [ -n "$snap" ]; then
128+ msg=$(git log -1 --format=%B "$snap")
129+ fg_version=$(printf '%s\n' "$msg" | sed -n 's/^Forkgram-Version: //p' | head -n 1)
130+ tg_version=$(printf '%s\n' "$msg" | sed -n 's/^Telegram-Base: .*(update to \([0-9.]*\).*/\1/p' | head -n 1)
131+ fg_version=${fg_version:-unknown}; tg_version=${tg_version:-unknown}
132+ fi
133+
134+ signed=${SIGNED:-false}
135+ prerelease=$PRERELEASE
136+ [ "$signed" = true ] || prerelease=true
137+
138+ notes=$(mktemp)
139+ {
140+ echo "MultiGram $version is Forkgram $fg_version (Telegram $tg_version) with the MultiGram changes listed below."
141+ echo
142+ if [ "$signed" = true ]; then
143+ echo "**Signing:** signed with the MultiGram release key. It installs as an update over earlier MultiGram releases signed with the same key."
144+ else
145+ echo "**Signing: public test key.** This APK is signed with Forkgram's public test key (\`TMessagesProj/config/test.keystore\`), because the repository has no \`MULTIGRAM_KEYSTORE_BASE64\` secret. Anyone can sign an \"update\" for it, and a later release signed with a real key cannot update it. Use it for testing only."
146+ fi
147+ echo
148+ echo "**Version:** $version (Android version code $version_code). Built from $sha."
149+ echo
150+ echo "**MultiGram changes on top of Forkgram $fg_version:**"
151+ echo
152+ if [ -n "$snap" ]; then
153+ git log --reverse --format='- %s (%h)' "$snap..HEAD"
154+ else
155+ echo "- (no Forkgram snapshot found in the history)"
156+ fi
157+ } > "$notes"
158+
159+ {
160+ echo "sha=$sha"
161+ echo "number=$n"
162+ echo "version=$version"
163+ echo "version_code=$version_code"
164+ echo "prerelease=$prerelease"
165+ echo "notes<<MULTIGRAM_NOTES_EOF"
166+ cat "$notes"
167+ echo "MULTIGRAM_NOTES_EOF"
168+ } >> "$GITHUB_OUTPUT"
169+ {
170+ if [ "$DRY_RUN" = true ]; then echo "## MultiGram $version (dry run: nothing built or published)"; else echo "## MultiGram $version"; fi
171+ echo
172+ echo "Pre-release: $prerelease"
173+ echo
174+ cat "$notes"
175+ } >> "$GITHUB_STEP_SUMMARY"
176+ cat "$notes"
177+
178+ build:
179+ name: Build
180+ needs: [check_secrets, prepare]
181+ if: ${{ !inputs.dry_run }}
182+ uses: ./.github/workflows/build.yml
183+ with:
184+ ref: ${{ needs.prepare.outputs.sha }}
185+ apk: true
186+ build_number: ${{ needs.prepare.outputs.number }}
187+ secrets: inherit
188+
189+ publish:
190+ name: Publish the release
191+ needs: [prepare, build]
192+ runs-on: ubuntu-latest
193+ permissions:
194+ contents: write
195+ steps:
196+ - uses: actions/download-artifact@v4
197+ with:
198+ name: multigram-apk
199+ path: apk
200+ - name: Check the APK and publish it
201+ env:
202+ GH_TOKEN: ${{ github.token }}
203+ SHA: ${{ needs.prepare.outputs.sha }}
204+ VERSION: ${{ needs.prepare.outputs.version }}
205+ VERSION_CODE: ${{ needs.prepare.outputs.version_code }}
206+ PRERELEASE: ${{ needs.prepare.outputs.prerelease }}
207+ NOTES: ${{ needs.prepare.outputs.notes }}
208+ run: |
209+ set -euo pipefail
210+ fail() { echo "::error::$1"; exit 1; }
211+ shopt -s nullglob
212+ apks=(apk/*.apk)
213+ [ "${#apks[@]}" -eq 1 ] || fail "Expected one APK in the build artifact, found ${#apks[@]}."
214+ file="MultiGram-$VERSION.apk"
215+ mv "${apks[0]}" "$file"
216+
217+ tools=$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -n 1)
218+ badging=$("$tools/aapt2" dump badging "$file" | head -n 1)
219+ echo "$badging"
220+ got_name=$(printf '%s' "$badging" | sed -n "s/.* versionName='\([^']*\)'.*/\1/p")
221+ got_code=$(printf '%s' "$badging" | sed -n "s/.* versionCode='\([^']*\)'.*/\1/p")
222+ [ "$got_name" = "$VERSION" ] || fail "The APK says versionName $got_name, expected $VERSION."
223+ [ "$got_code" = "$VERSION_CODE" ] || fail "The APK says versionCode $got_code, expected $VERSION_CODE."
224+ cert=$("$tools/apksigner" verify --print-certs "$file" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' | head -n 1)
225+ [ -n "$cert" ] || fail "apksigner could not verify the APK's signature."
226+ sha256sum "$file" > SHA256SUMS
227+
228+ {
229+ printf '%s\n' "$NOTES"
230+ echo
231+ echo "**Checksums:** \`$(cut -d' ' -f1 SHA256SUMS)\` ($file, SHA-256). Signing certificate SHA-256: \`$cert\`."
232+ } > notes.md
233+
234+ flags=()
235+ if [ "$PRERELEASE" = true ]; then flags+=(--prerelease); fi
236+ gh release create "$VERSION" "$file" SHA256SUMS \
237+ --repo "$GITHUB_REPOSITORY" \
238+ --target "$SHA" \
239+ --title "MultiGram $VERSION" \
240+ --notes-file notes.md \
241+ "${flags[@]}"
242+ echo "Published https://github.com/$GITHUB_REPOSITORY/releases/tag/$VERSION" | tee -a "$GITHUB_STEP_SUMMARY"
.github/workflows/sync-forkgram.ymlnew file+177
@@ -0,0 +1,177 @@
1+name: Sync with Forkgram
2+run-name: Sync with Forkgram${{ inputs.use_next && ' (use multigram-next)' || '' }}${{ inputs.allow_drop && ' (allow drop)' || '' }}${{ inputs.force && ' (force)' || '' }}
3+
4+# Every 6 hours: snapshot a new Forkgram release onto "forkgram", replay the MultiGram
5+# stack onto it as "multigram-next", compile that, and only if it compiles move "multigram"
6+# to it. Conflicts and failures go to an issue labelled forkgram-sync; multigram stays as
7+# it was. A multigram-next changed by hand is never replaced. See README.md and
8+# scripts/sync-forkgram.sh.
9+on:
10+ schedule:
11+ - cron: '23 */6 * * *'
12+ workflow_dispatch:
13+ inputs:
14+ force:
15+ description: Rebuild multigram-next even without a new Forkgram release (tests the pipeline, or retries after a failed compile)
16+ type: boolean
17+ default: false
18+ use_next:
19+ description: Skip the rebase; compile and promote the multigram-next branch you fixed by hand
20+ type: boolean
21+ default: false
22+ allow_drop:
23+ description: Only with use_next. Accept a multigram-next that leaves out some of multigram's commits (Forkgram has them now, or you dropped them on purpose)
24+ type: boolean
25+ default: false
26+
27+permissions: {}
28+
29+concurrency:
30+ group: forkgram-sync
31+ cancel-in-progress: false
32+
33+jobs:
34+ sync:
35+ name: Snapshot Forkgram and replay the stack
36+ runs-on: ubuntu-latest
37+ timeout-minutes: 60
38+ permissions:
39+ contents: write # push forkgram, multigram-next and refs/multigram-sync/next
40+ issues: read # see whether the report job has anything to do
41+ outputs:
42+ status: ${{ steps.sync.outputs.status }}
43+ message: ${{ steps.sync.outputs.message }}
44+ error: ${{ steps.sync.outputs.error }}
45+ new_snapshot: ${{ steps.sync.outputs.new_snapshot }}
46+ forkgram_commit: ${{ steps.sync.outputs.forkgram_commit }}
47+ forkgram_version: ${{ steps.sync.outputs.forkgram_version }}
48+ snapshot: ${{ steps.sync.outputs.snapshot }}
49+ previous_snapshot: ${{ steps.sync.outputs.previous_snapshot }}
50+ multigram: ${{ steps.sync.outputs.multigram }}
51+ multigram_base: ${{ steps.sync.outputs.multigram_base }}
52+ candidate: ${{ steps.sync.outputs.candidate }}
53+ tag_version: ${{ steps.sync.outputs.tag_version }}
54+ conflict_commit: ${{ steps.sync.outputs.conflict_commit }}
55+ conflict_subject: ${{ steps.sync.outputs.conflict_subject }}
56+ conflict_position: ${{ steps.sync.outputs.conflict_position }}
57+ conflict_files: ${{ steps.sync.outputs.conflict_files }}
58+ conflict_details: ${{ steps.sync.outputs.conflict_details }}
59+ dropped: ${{ steps.sync.outputs.dropped }}
60+ existing_next: ${{ steps.sync.outputs.existing_next }}
61+ forkgram_tip: ${{ steps.sync.outputs.forkgram_tip }}
62+ report: ${{ steps.need.outputs.report }}
63+ steps:
64+ # Blobless: the history and trees of forkgram and multigram come cheap, and file
65+ # contents are fetched only when the rebase needs them.
66+ - uses: actions/checkout@v4
67+ with:
68+ fetch-depth: 1
69+ filter: blob:none
70+ - name: Sync
71+ id: sync
72+ env:
73+ FORCE: ${{ inputs.force }}
74+ USE_NEXT: ${{ inputs.use_next }}
75+ ALLOW_DROP: ${{ inputs.allow_drop }}
76+ run: |
77+ git config user.name 'github-actions[bot]'
78+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
79+ args=(--remote origin --push)
80+ if [ "$FORCE" = true ]; then args+=(--force); fi
81+ if [ "$USE_NEXT" = true ]; then args+=(--use-next); fi
82+ if [ "$ALLOW_DROP" = true ]; then args+=(--allow-drop); fi
83+ bash scripts/sync-forkgram.sh "${args[@]}"
84+ # Most scheduled runs find nothing new. Skip the report job then, since every job
85+ # costs at least a billed minute: it only has work when an issue is open to close
86+ # (up-to-date), or none is open yet (behind).
87+ - name: Check whether the report job has anything to do
88+ id: need
89+ if: steps.sync.outputs.status == 'up-to-date' || steps.sync.outputs.status == 'behind'
90+ env:
91+ GH_TOKEN: ${{ github.token }}
92+ GH_REPO: ${{ github.repository }}
93+ STATUS: ${{ steps.sync.outputs.status }}
94+ run: |
95+ open=$(gh issue list --label forkgram-sync --state open --json number --jq length) || open=unknown
96+ echo "Open forkgram-sync issues: $open"
97+ case "$STATUS:$open" in
98+ up-to-date:0 | behind:[1-9]*) echo report=no >>"$GITHUB_OUTPUT" ;;
99+ *) echo report=yes >>"$GITHUB_OUTPUT" ;;
100+ esac
101+
102+ compile:
103+ name: Compile multigram-next
104+ needs: sync
105+ if: needs.sync.outputs.candidate != ''
106+ permissions:
107+ contents: read
108+ uses: ./.github/workflows/build.yml
109+ with:
110+ ref: ${{ needs.sync.outputs.candidate }}
111+
112+ promote:
113+ name: Move multigram to multigram-next
114+ needs: [sync, compile]
115+ if: needs.compile.result == 'success'
116+ runs-on: ubuntu-latest
117+ timeout-minutes: 15
118+ permissions:
119+ contents: write # move multigram, tag its old tip, delete multigram-next
120+ outputs:
121+ status: ${{ steps.promote.outputs.status }}
122+ tag: ${{ steps.promote.outputs.tag }}
123+ steps:
124+ - uses: actions/checkout@v4
125+ with:
126+ fetch-depth: 1
127+ filter: blob:none
128+ - name: Promote
129+ id: promote
130+ env:
131+ CANDIDATE: ${{ needs.sync.outputs.candidate }}
132+ PREVIOUS: ${{ needs.sync.outputs.multigram }}
133+ VERSION: ${{ needs.sync.outputs.tag_version }}
134+ run: |
135+ bash scripts/sync-forkgram.sh promote --remote origin --shallow \
136+ --candidate "$CANDIDATE" --previous "$PREVIOUS" --version "$VERSION"
137+
138+ report:
139+ name: Report on the forkgram-sync issue
140+ needs: [sync, compile, promote]
141+ if: ${{ !cancelled() && needs.sync.outputs.report != 'no' }}
142+ runs-on: ubuntu-latest
143+ timeout-minutes: 10
144+ permissions:
145+ contents: read # the report script
146+ issues: write # open, comment on and close the forkgram-sync issue
147+ steps:
148+ - uses: actions/checkout@v4
149+ with:
150+ sparse-checkout: scripts
151+ - name: Open, update or close the issue
152+ env:
153+ GH_TOKEN: ${{ github.token }}
154+ GH_REPO: ${{ github.repository }}
155+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
156+ SYNC_RESULT: ${{ needs.sync.result }}
157+ COMPILE_RESULT: ${{ needs.compile.result }}
158+ PROMOTE_RESULT: ${{ needs.promote.result }}
159+ PROMOTE_TAG: ${{ needs.promote.outputs.tag }}
160+ SYNC_STATUS: ${{ needs.sync.outputs.status }}
161+ SYNC_MESSAGE: ${{ needs.sync.outputs.message }}
162+ SYNC_ERROR: ${{ needs.sync.outputs.error }}
163+ SYNC_FORKGRAM_COMMIT: ${{ needs.sync.outputs.forkgram_commit }}
164+ SYNC_FORKGRAM_VERSION: ${{ needs.sync.outputs.forkgram_version }}
165+ SYNC_SNAPSHOT: ${{ needs.sync.outputs.snapshot }}
166+ SYNC_PREVIOUS_SNAPSHOT: ${{ needs.sync.outputs.previous_snapshot }}
167+ SYNC_MULTIGRAM_BASE: ${{ needs.sync.outputs.multigram_base }}
168+ SYNC_CANDIDATE: ${{ needs.sync.outputs.candidate }}
169+ SYNC_CONFLICT_COMMIT: ${{ needs.sync.outputs.conflict_commit }}
170+ SYNC_CONFLICT_SUBJECT: ${{ needs.sync.outputs.conflict_subject }}
171+ SYNC_CONFLICT_POSITION: ${{ needs.sync.outputs.conflict_position }}
172+ SYNC_CONFLICT_FILES: ${{ needs.sync.outputs.conflict_files }}
173+ SYNC_CONFLICT_DETAILS: ${{ needs.sync.outputs.conflict_details }}
174+ SYNC_DROPPED: ${{ needs.sync.outputs.dropped }}
175+ SYNC_EXISTING_NEXT: ${{ needs.sync.outputs.existing_next }}
176+ SYNC_FORKGRAM_TIP: ${{ needs.sync.outputs.forkgram_tip }}
177+ run: python3 scripts/sync-report.py
PATCH.mddeleted−3719
@@ -1,3719 +0,0 @@
1−# MultiGram patch guide
2−
3−This guide says how to keep MultiGram working after Telegram, and then Forkgram, release an update.
4−
5−MultiGram is Forkgram plus six commits of its own. Usually the automatic sync moves those commits onto each new Forkgram release by itself. When it cannot, this guide tells you what to do. It also lists every place where MultiGram edits a Forkgram file, with the exact text, so that you can put each change back by hand.
6−
7−## 1. About this document
8−
9−### 1.1 Stamp
10−
11−| | |
12−|---|---|
13−| Written for | `multigram` at 4780eb095b |
14−| Built on | Forkgram 12.10.6 (Forkgram commit b994e1446e), which is Telegram 12.10.5 (DrKLO commit dc780e81e, "update to 12.10.5 (7105)") |
15−| Forkgram snapshot | `forkgram` at 4543767655 |
16−| Sync scripts | `main` at da0cc7c548 |
17−| Date | 2026-09-27 |
18−
19−Line numbers, counts and outputs in this guide are from that state. When you use it after an update, expect line numbers to have moved. Every hook is therefore described by its content, not only by its line. Section 11 says how to bring this guide up to date.
20−
21−Output marked "example output" comes from a test with a made-up Forkgram release. Its commit hashes do not exist in the real repository.
22−
23−### 1.2 Words used here
24−
25−| Word | Meaning in this guide |
26−|---|---|
27−| Upstream | The code MultiGram builds on: Forkgram, which is Telegram's Android app (DrKLO/Telegram) plus Forkgram's own changes. |
28−| Snapshot | A commit on the `forkgram` branch that holds one Forkgram release, without its `.github` folder. |
29−| Stack | The six MultiGram commits on top of the snapshot. Together with the snapshot they form the `multigram` branch. |
30−| Hook | A small edit in an upstream file that calls MultiGram code. Most hooks are one line that ends with `// MultiGram: ...`. |
31−| New file | A file the stack adds. It never conflicts, but it can stop compiling when an upstream name it uses changes. |
32−| Replay | Moving the stack onto a new snapshot with `git rebase`. |
33−| Conflict | A place where git cannot combine an upstream change with a MultiGram change by itself. |
34−| rerere | Git's "reuse recorded resolution": it remembers how you fixed a conflict and repeats the fix, but only in the same clone. |
35−| Worktree | An extra working folder of the same clone, made with `git worktree add`, with its own branch checked out. |
36−| CI | "Continuous integration": here, the GitHub Actions workflows that compile and check each candidate. |
37−| Static initializer | Java code in a `static { ... }` block. It runs once, when the class is first used. `Theme`'s static initializer loads the themes at app start. |
38−| ARGB | A colour as four bytes: alpha (opacity), red, green, blue, written `0xAARRGGBB` or `#AARRGGBB`. `.attheme` files store it as a signed decimal number. |
39−| Accent | The main colour of a theme. A **runtime accent** is one made on the device (MultiGram's generated style, or a colour picked with the colour wheel). Its id is above 100, and it either has no server theme or has one the user created (`accent.info.creator`, for example a custom accent the user shared). |
40−| Contrast, WCAG | How readable a colour is on its background, as a ratio from 1 : 1 to 21 : 1. The usual minimum (the WCAG guideline) is 4.5 : 1 for text and 3 : 1 for icons. |
41−| Overlay A | The palette fix's list of colour changes, in `multigram/palette-fix/overlay_A.json`. |
42−| Simulator | Python code in `multigram/tools/sim/` that recomputes Telegram's theme colours without Android. It makes overlay A and checks the style table. |
43−| Seed | A random number stored once per install. The random style and the shapes are derived from it. |
44−| Knob | One shape or layout choice derived from the seed, such as the bubble corner radius. |
45−| use_next | An input of the sync workflow. It means "take my fixed `multigram-next` as it is, compile it, and promote it". |
46−
47−### 1.3 Branches
48−
49−| Branch or tag | What it holds |
50−|---|---|
51−| `forkgram` | One snapshot commit per Forkgram release. |
52−| `multigram` | The latest snapshot plus the stack. This is the app. |
53−| `main` | README, the workflows and the scripts in `scripts/`. |
54−| `multigram-next` | The next `multigram`, while it is being checked or fixed. |
55−| `multigram-before-<version>` | A tag on each `multigram` the sync replaced. |
56−| `docs` | This guide. |
57−
58−### 1.4 What you need
59−
60−- A clone of `iko-soy/Multigram` with the right to push branches and run workflows.
61−- git, bash, and Python 3.9 or later. The MultiGram tools use only Python's standard library.
62−- For a local compile only: JDK 17 and the Android SDK. The CI has both, so you can skip this.
63−- Optional: the GitHub CLI `gh`. The `gh` commands in this guide were not run while writing it.
64−
65−### 1.5 How a hook is described
66−
67−Section 8 describes each of the 62 hooks in the same way:
68−
69−- **File** and **Where**: the file, the class and method, and (for upstream files) the line where the text starts in Forkgram 12.10.6.
70−- **Find this**: text to search for. It is copied from the file, so a plain text search finds it. For K1 and for K18 to K24 it is the text as an earlier stack commit left the file, so it has no Forkgram line number.
71−- **Change it to**: the change as a diff, copied from the stack commit. Lines starting with `-` go, lines starting with `+` are added, and the other lines are context that stays.
72−- **Why**: what the hook does.
73−- **If the code moved**: how to find the right place when the text above is gone.
74−
75−## 2. After a Telegram update: what to do
76−
77−1. Open the repository's **Issues** and look for an open issue labelled `forkgram-sync`.
78−2. Open **Actions > Sync with Forkgram** and look at the latest run.
79−3. Pick the row that matches:
80−
81−| What you see | What it means | Go to |
82−|---|---|---|
83−| No open issue. The last run is green, and `multigram` sits on the new snapshot (see the command below). | The stack moved by itself. | Build an APK and test it (10.4, 10.5). Also re-derive the palette values once (8.2.5, a few seconds): a colour change upstream can make them too weak without any conflict. |
84−| An issue "Forkgram sync needs a hand" that says the stack "does not apply" | A conflict: git could not replay one of the commits. | Section 5 |
85−| An issue that says "the compile check failed", and the failed step is **Compile** | The Java code no longer compiles. | Section 6 |
86−| The same, but the failed step is **Run MultiGram tool checks** | `check.sh` found a problem. | 10.2 |
87−| The rebase conflicts in many places, or Forkgram moved or rewrote the code around the hooks | Replaying is not practical. | Section 7, with section 8 for each hook |
88−| Forkgram released, but there is no run and no issue | The schedule has not run yet, or Forkgram's `dev` branch has not moved. | Section 4.3 (run the sync by hand) |
89−| The issue or the run says `next-edited` | A fix of yours on `multigram-next` is waiting. The sync did not replace it. | 4.2 |
90−| The issue or the run says `behind` | `multigram` is still on an older snapshot, because an earlier sync could not move it. | 4.2: run the sync with **force**. If it conflicts, section 5. |
91−| Telegram has released, but Forkgram has not moved for months | Forkgram has slowed down or stopped. | 4.5 |
92−| You want to build on plain Telegram instead of Forkgram | | Section 9 |
93−
94−To see which Forkgram release `multigram` is built on:
95−
96−```sh
97−git fetch origin
98−git log -1 --format=%B origin/forkgram
99−git merge-base --is-ancestor origin/forkgram origin/multigram && echo "multigram is on the latest snapshot"
100−```
101−
102−## 3. The stack at a glance
103−
104−### 3.1 The six commits
105−
106−| Commit | What it does | Upstream files touched | New files | How it is checked |
107−|---|---|---|---|---|
108−| d457ac74ae Add the rebrand toolkit | A build-time tool that gives a build its own name, app id, icons, signing key and account type. A stock build is unchanged. | 4: `AppIconsSelectorCell.java`, `DialogsActivity.java`, `ForkSettingsActivity.java`, `TMessagesProj_App/build.gradle` (+9 −3 lines; hooks R1–R5) | 7 | `multigram/rebrand/selftest.py` (run by `check.sh` since 4780eb095b). No script checks the four Java hooks: use the `git grep` in 10.3. |
109−| 5b53859682 Fix palette contrast for generated accents | Makes grey texts readable (contrast 4.5 : 1), and makes marks on accent fills readable for runtime accents. | 13: `Theme.java`, `ThemeColors.java`, `EmojiThemes.java`, `ChannelColorActivity.java`, `ChatActivityEnterView.java`, `MessageEntityView.java`, `PeerColorActivity.java`, `DarkThemeResourceProvider.java` (+30 −18 lines), and 5 `.attheme` files (36 values); hooks P1–P18 | 4 | `multigram/tools/palette_fix_check.py`; the simulator (8.2.5) |
110−| cddd62df98 Add the style table and its generator | A precomputed table of 20,480 readable colour styles, and the Python tools that make and check it. Also creates `check.sh`. | none | 17 | `check_style_table.py`, `make_style_table.py --check` |
111−| 27d32d57b4 Give each install its own random style | Each fresh install picks a day and a night style from the table. Adds "Shuffle my style" to Chat Settings. Generated styles are never uploaded. | 7: `ApplicationLoader.java`, `MessagesController.java`, `Theme.java`, `AlertsCreator.java`, `DefaultThemesPreviewCell.java`, `ThemeActivity.java`, `ThemePreviewActivity.java` (+16 lines; hooks S1–S15) | 6. It also changes `check.sh` and `multigram/tools/README.md`. | `multigram/tools/random_style_check.py` |
112−| 3b941c01de Randomise shapes and chat list layout per install | Derives corner radii, the chat list layout and the wallpaper phase from the install's seed. | 10: `ApplicationLoader.java`, `Theme.java`, `ThemeActivity.java`, `ChatActionCell.java`, `FragmentFloatingButton.java`, `GradientButtonWithCounterView.java`, `ReactionsLayoutInBubble.java`, `RecyclerListView.java`, `UniversalRecyclerView.java`, `ButtonWithCounterView.java` (+23 −16 lines; hooks K1–K17) | 3. It also changes `RandomStyle.java` and `RandomStyleUi.java` (hooks K18–K22), `check.sh` (K23), and two READMEs. | `multigram/tools/style_knobs_check.py` |
113−| 4780eb095b Run the rebrand self-test in the CI checks | Adds the rebrand self-test to `check.sh`. | none | none. It changes `check.sh` (K24). | `check.sh` itself |
114−
115−In total the stack adds 37 new files and touches 30 upstream files:
116−
117−- 25 Java or Gradle files: 78 added lines (3 of them blank) and 37 removed lines. Most removed lines are replaced by a changed copy.
118−- 5 `.attheme` files: 36 changed values.
119−
120−72 of the added lines carry the word `MultiGram`. The other six are the 3 blank lines, and 3 lines whose `// MultiGram:` comment sits on the line above them (hooks R2, R3 and R5).
121−
122−### 3.2 Why the order matters
123−
124−Keep the six commits in this order. Later commits build on earlier ones:
125−
126−- 27d32d57b4 needs 5b53859682: `RandomStyle` uses `PaletteFix.LAST_STOCK_ACCENT_ID`, and generated styles are only readable with the palette fix.
127−- 27d32d57b4 needs cddd62df98: it reads the style table asset, and it adds a step to `check.sh`, which cddd62df98 creates.
128−- 3b941c01de needs 27d32d57b4: `StyleKnobs` uses `RandomStyle`, hook K1 sits on the line after hook S1, and the commit changes `RandomStyle.java` and `RandomStyleUi.java`.
129−- 4780eb095b needs d457ac74ae (the self-test) and cddd62df98 (`check.sh`).
130−- The file `overlay_A.json` exists twice: `multigram/palette-fix/overlay_A.json` (from 5b53859682) and `multigram/tools/sim/palette-fix/overlay_A.json` (from cddd62df98). The two copies must stay byte-identical. No script checks this; the `cmp` line in 5.4 does.
131−
132−## 4. The automatic sync, briefly
133−
134−The full description is in `README.md` on `main` ("How the sync works" and "When the sync issue opens"), and in `bash scripts/sync-forkgram.sh --help`. This section is a summary.
135−
136−### 4.1 What it does
137−
138−The workflow **Sync with Forkgram** (`.github/workflows/sync-forkgram.yml` on `main`) runs every 6 hours, at 23 minutes past the hour (`cron: '23 */6 * * *'`, UTC). You can also start it by hand. It has four jobs:
139−
140−1. **sync** runs `scripts/sync-forkgram.sh`. It reads the tip of Forkgram's `dev` branch. If the tip is new, it adds a snapshot commit to `forkgram`. Then it replays the stack onto the new snapshot as `multigram-next`, with `git rebase --onto`. It pushes `forkgram` and `multigram-next` in one push.
141−2. **compile** runs the workflow **Build MultiGram** on `multigram-next`: the Java compile and `check.sh` (see 10.1).
142−3. **promote** runs only if the compile passed. It tags the old `multigram` as `multigram-before-<version>`, moves `multigram` to `multigram-next`, and deletes `multigram-next`.
143−4. **report** (`scripts/sync-report.py`) opens, updates or closes the issue labelled `forkgram-sync`. The issue title is "Forkgram sync needs a hand (Forkgram <version>)". It holds the exact commands for the case at hand.
144−
145−A new snapshot's commit message names its source in the lines `Source:`, `Forkgram-Commit:`, `Forkgram-Version:` and `Telegram-Base:`. The last one is left out when the sync cannot find Telegram's "update to ..." commit in Forkgram's history.
146−
147−### 4.2 What each outcome means
148−
149−The sync script ends by printing `status=<value>`.
150−
151−| Status | What it means | What you do |
152−|---|---|---|
153−| `up-to-date` | Nothing new. | Nothing. |
154−| `candidate` | The stack was replayed onto the new snapshot. The compile job runs next. | Wait for the run to finish. |
155−| `promoted` (promote job) | `multigram` moved to the new release. The old tip is tagged `multigram-before-<version>`. | Build an APK and test it (10.4, 10.5). |
156−| `unchanged` (promote job) | `multigram` already was the candidate. `multigram-next` was deleted. | Nothing. |
157−| `conflict` (exit status 2) | A stack commit does not apply. `forkgram` has the new snapshot, and `multigram` is unchanged. | Section 5. |
158−| A failed compile job | The stack applied, but the compile or `check.sh` failed. `multigram-next` holds the candidate. | Section 6. |
159−| `next-edited` | `multigram-next` has commits the sync did not make (your fix). The sync changed nothing. | Run the workflow with **use_next**, or delete the branch (`git push origin --delete multigram-next`). |
160−| `behind` | No new release, but `multigram` is still on an older snapshot, because an earlier problem was never fixed. | Finish the fix and run **use_next**, or run with **force** to retry. |
161−| `error` | Something else: Forkgram unreachable, a rejected push, a `.github` folder in the stack, or a merge commit with changes of its own. | Read the message on the issue, fix the cause, run again. |
162−
163−Keep `multigram` linear: rebase, never merge. The replay flattens merges, and a merge commit with changes of its own stops the sync.
164−
165−### 4.3 Running the sync by hand
166−
167−In the browser: **Actions > Sync with Forkgram > Run workflow**. The inputs:
168−
169−| Input | Use it when |
170−|---|---|
171−| (none) | You want an extra run now, for example right after a Forkgram release. |
172−| **force** | You want to rebuild `multigram-next` although Forkgram has no new release: to retry after a failed compile, or to test the pipeline. |
173−| **use_next** | You fixed `multigram-next` yourself. The run compiles it and, if that passes, promotes it. It first checks that `multigram-next` carries every commit on `multigram`, matched by author and subject, or by patch content. |
174−| **allow_drop** | Only with **use_next**: your `multigram-next` leaves out some commits on purpose. |
175−
176−The same from a terminal (not run while writing this guide):
177−
178−```sh
179−gh workflow run sync-forkgram.yml -R iko-soy/Multigram --ref main -f use_next=true
180−gh workflow run sync-forkgram.yml -R iko-soy/Multigram --ref main -f force=true
181−gh run list -R iko-soy/Multigram --workflow sync-forkgram.yml -L 5
182−gh run rerun <run-id> -R iko-soy/Multigram --failed
183−```
184−
185−The last command is the same as the **Re-run failed jobs** button. Use it when a run failed because of a runner or network problem.
186−
187−### 4.4 Running the sync locally
188−
189−This uses your clone's rerere memory (5.6). Run it from a checkout of `main`. It refuses to run while `forkgram` or `multigram-next` is checked out in any worktree.
190−
191−```sh
192−git fetch origin
193−git switch main
194−bash scripts/sync-forkgram.sh --remote origin
195−```
196−
197−That leaves the results in your local `forkgram` and `multigram-next` branches. Add `--push` to push them too. Sections 5 to 7 start from `origin/forkgram`, so use `--push` when you go on to them. After a local `--push`, start the workflow with **use_next**, not **force**: a forced run starts from a fresh clone without your rerere memory, and would hit the same conflict again.
198−
199−To test the sync scripts themselves against fake repositories:
200−
201−```sh
202−bash scripts/selftest-sync.sh
203−```
204−
205−On `main` da0cc7c548 it ended with `183 passed, 0 failed` after about 10 seconds.
206−
207−### 4.5 When Forkgram stops
208−
209−Forkgram follows Telegram with a delay. If Telegram has released and Forkgram's `dev` branch has not moved for months, compare the `Telegram-Base` line of `git log -1 --format=%B origin/forkgram` with the newest "update to ..." commit at https://github.com/DrKLO/Telegram. Then choose one of these:
210−
211−- **Stay on the last snapshot.** Nothing to do. The scheduled sync reports `up-to-date` until Forkgram moves again.
212−- **Follow another fork.** The sync script reads its source from the environment variables `FORKGRAM_URL` and `FORKGRAM_BRANCH` (defaults `https://github.com/forkgram/TelegramAndroid` and `dev`), or from the options `--url` and `--branch`. It takes the version from `APP_VERSION_NAME` in the source's `gradle.properties`. On Actions, add both variables to the `env:` of the **Sync** step in `.github/workflows/sync-forkgram.yml` on `main`, then run the workflow. The workflow does not set them today.
213−- **Move to plain Telegram.** Read section 9 first. The sync can take Telegram itself as the source, in the same way.
214−
215−To see what the sync makes of another source before you change the workflow, run it locally from a checkout of `main` (4.4), without `--push`:
216−
217−```sh
218−bash scripts/sync-forkgram.sh --remote origin --url https://github.com/DrKLO/Telegram --branch master
219−```
220−
221−On 2026-09-27, with DrKLO's `master` at dc780e81e, it made a snapshot titled `Forkgram 12.10.5 snapshot` (the script says "Forkgram" whatever the source) with a correct `Telegram-Base` line. Then it ended with `status=conflict` at commit 1 of 6: the three conflicts of 9.1. With any new source, expect to settle conflicts with section 5 or 7. Afterwards update the stamp (11.2).
222−
223−This trial run leaves its snapshot in your local `forkgram` branch, and a trial `multigram-next` if the replay went through. A later local sync that finds nothing new does not reset them. Never push them: 5.1 pushes your local `forkgram`. Put `forkgram` back with `git branch -f forkgram origin/forkgram`. If there is a trial `multigram-next`, delete it with `git branch -D multigram-next`.
224−
225−## 5. Fixing a conflicting replay
226−
227−Use this section when the sync issue says the stack "does not apply". The issue names the commit that stopped and the files involved.
228−
229−### 5.1 Replay the stack by hand
230−
231−The new snapshot must be on `origin/forkgram`. After a sync on Actions it is. If you ran the sync locally (4.4) without `--push`, push the snapshot first with `git push origin forkgram`.
232−
233−```sh
234−git fetch origin
235−git switch -C multigram-next origin/multigram
236−git config rerere.enabled true
237−git rebase origin/forkgram
238−```
239−
240−The third line switches on rerere for this clone (5.6). Git replays the six commits one by one. It stops at the first one that conflicts and names the files. If git answers `Current branch multigram-next is up to date.`, `origin/forkgram` is still the old snapshot: `git log -1 --format=%B origin/forkgram` shows which release it holds.
241−
242−`git switch -C` resets a local `multigram-next`. If yours holds work you have not pushed and want to keep, rename it first: `git branch -m multigram-next my-fix`.
243−
244−The issue shows a longer form, `git rebase --onto <new snapshot> <old snapshot>`. It does the same thing, because each new snapshot is a child of the previous one.
245−
246−### 5.2 Read and fix a conflict
247−
248−Example output, from a test with a made-up Forkgram 12.10.7 that changed the line under hook S2:
249−
250−```text
251−Rebasing (1/6)Rebasing (2/6)Rebasing (3/6)Rebasing (4/6)Auto-merging TMessagesProj/src/main/java/org/telegram/messenger/MessagesController.java
252−CONFLICT (content): Merge conflict in TMessagesProj/src/main/java/org/telegram/messenger/MessagesController.java
253−error: could not apply 27d32d57b4... Give each install its own random style
254−```
255−
256−The file then contains this (example output; the first line inside the markers is the made-up Forkgram change):
257−
258−```text
259−<<<<<<< HEAD
260− if (themeInfo == null || accent == null) {
261−=======
262− if (org.telegram.messenger.multigram.RandomStyle.isUploadBlocked(themeInfo, accent)) return; // MultiGram: a generated style never leaves the device
263− if (themeInfo == null) {
264−>>>>>>> 27d32d57b4 (Give each install its own random style)
265− return;
266− }
267−```
268−
269−How to read it:
270−
271−- Between `<<<<<<< HEAD` and `=======` is the **new Forkgram** code.
272−- Between `=======` and `>>>>>>>` is the **old Forkgram** code with the MultiGram hook in it.
273−- Never take the MultiGram side as a whole. It would bring back Forkgram's old code.
274−- Git also reports a conflict when Forkgram only changed a line next to a hook, as here.
275−
276−How to fix it depends on the hook. Its "Change it to" block in section 8 shows which kind it is:
277−
278−- **The hook only adds lines** (the block has no `-` line), as S2 here. Keep Forkgram's new code and put the MultiGram line back where the hook's "Where" says. If Forkgram added a line at the same spot, the hook's "Where" and "If the code moved" say which comes first. For example, P11 goes after every calculated-colour call, and S10 goes directly after `themeListRow2 = rowCount++;`.
279−- **The hook replaces an upstream line** (the block has a `-` line), as R2 to R4, P1 to P6, P8, P12, P14, P15, P18, K3 to K7, K9 to K14 and K16. Take Forkgram's new line and make the MultiGram change to it again. Keep only that one line, not both. For example, if Forkgram changed the default title from `"Fork Client"` to `"Forkgram"`, R2 becomes `getString("forkCustomTitle", org.telegram.messenger.multigram.Rebrand.defaultTitle("Forkgram"))`. For the colour lines of P1 to P6, 5.3 says what to do.
280−- Delete the three marker lines.
281−
282−Four hooked files end without a final newline: `assets/arctic.attheme`, `assets/day.attheme`, `ui/Components/RecyclerListView.java` and `ui/PeerColorActivity.java`. Keep it that way. Many editors add one when they save (in vim, `:set nofixendofline` stops that). Check with `git diff`: it must not show the last line as changed. An added newline makes `palette_fix_check.py --base` report `unexpected arctic.attheme change: <last line>`, and `style_knobs_check.py --base` report `added line without a "MultiGram:" marker: }`.
283−
284−The fixed text in this example:
285−
286−```text
287− if (org.telegram.messenger.multigram.RandomStyle.isUploadBlocked(themeInfo, accent)) return; // MultiGram: a generated style never leaves the device
288− if (themeInfo == null || accent == null) {
289−```
290−
291−To see which hooks the stopped commit puts in a file, run `git show REBASE_HEAD -- <file>`. Section 8 explains each hook, and 5.3 says what a conflict in each file usually means.
292−
293−When a file is fixed:
294−
295−```sh
296−git add <file>
297−git rebase --continue
298−```
299−
300−Repeat until git prints `Successfully rebased and updated refs/heads/multigram-next.` To give up and go back, run `git rebase --abort`.
301−
302−### 5.3 What a conflict in each file usually means
303−
304−| File | Hooks | A conflict here usually means |
305−|---|---|---|
306−| `ui/Cells/AppIconsSelectorCell.java` | R1 | Forkgram changed how the app icon list is built. Put the filter call after the list is filled. |
307−| `ui/DialogsActivity.java` | R2 | Forkgram changed the chat list title code. Wrap every literal default of `forkCustomTitle` again. |
308−| `ui/ForkSettingsActivity.java` | R3, R4 | Forkgram changed its own settings screen. Wrap the custom title defaults again. If the file was renamed, git reports it as deleted: find the new file with `git grep -n '"forkCustomTitle"'`. |
309−| `TMessagesProj_App/build.gradle` | R5 | Forkgram added lines at the end of the file. Keep them, and keep the `apply from:` line last. |
310−| `ui/ActionBar/ThemeColors.java` | P1 | Forkgram changed a stock colour next to, or on, one of the 11 grey lines. Take MultiGram's line for the 11 keys that carry `// MultiGram: contrast fix`, and Forkgram's line for every other key. The simulator is not in the tree at this stop (it comes with cddd62df98), so re-derive the values after the rebase (5.4). |
311−| `assets/*.attheme` (5 files) | P2–P6 | Forkgram changed a colour of a bundled theme. As for ThemeColors: MultiGram's value for the keys of P2 to P6, Forkgram's for all others, and re-derive after the rebase. |
312−| `ui/ActionBar/EmojiThemes.java` | P7 | Forkgram changed how per-chat themes build their colours. Put the call back after `fillAccentColors`, in `createColors` only. |
313−| `ui/ActionBar/Theme.java` | P8–P12, S3, S4, K2 | Forkgram changed the theme engine. Keep the new code and put each hook back at the same logical place. Then diff the engine methods, because the simulator copies them (8.3.6), and re-derive the palette values (8.2.5). |
314−| `ui/ChannelColorActivity.java`, `ui/PeerColorActivity.java`, `ui/Components/Paint/Views/MessageEntityView.java` | P13, P17, P16 | The screen's preview colour code changed. Put the call back after `fillAccentColors`. |
315−| `ui/Components/ChatActivityEnterView.java` | P14, P15 | The send button's drawing code changed. Every mark drawn on the button's fill must use `multigramGlyphColor` when it is not 0. |
316−| `ui/Stories/DarkThemeResourceProvider.java` | P18 | The dark provider's colour lookup changed. Its fallback must go through `getOverrideFallbackColor`. |
317−| `messenger/ApplicationLoader.java` | S1, K1 | Forkgram changed the app start. S1 and K1 must still run after `applicationContext` is set and before anything loads `Theme`. |
318−| `messenger/MessagesController.java` | S2 | Forkgram changed the theme upload. The guard must stay the first statement of `saveThemeToServer`. |
319−| `ui/Components/AlertsCreator.java` | S5 | The "create theme" dialog changed. The call goes right after the fragment check. |
320−| `ui/DefaultThemesPreviewCell.java` | S6 | The theme strip changed. The call stays the first statement of `updateDayNightMode()`. |
321−| `ui/ThemePreviewActivity.java` | S7 | The accent editor changed. The call stays right after the editor gets its accent. |
322−| `ui/ThemeActivity.java` | S8–S15, K15–K17 | Chat Settings changed: rows, menus or the list adapter. Put each hook back by its landmark (the rows, the reset menu, the click listener, the view types). `random_style_check.py` and `style_knobs_check.py` check the placement. |
323−| `ui/Cells/ChatActionCell.java` | K3, K4 | The service pill drawing changed. Wrap the corner constants again, and keep the offset in step. |
324−| `ui/Components/FragmentFloatingButton.java` | K5–K7 | The floating button changed. Route its background, outline and small-button radius through `StyleKnobs` again. |
325−| `ui/Components/Premium/boosts/GradientButtonWithCounterView.java` | K8 | The gradient button changed. Keep `setRoundRadius(8)` at the end of its constructor. |
326−| `ui/Components/Reactions/ReactionsLayoutInBubble.java` | K9, K10 | Reaction chip drawing changed. Both radius lines must use the same function. |
327−| `ui/Components/RecyclerListView.java`, `ui/Components/UniversalRecyclerView.java` | K11, K12 | The settings card code changed. Wrap the default radius in every default `setSections` overload. |
328−| `ui/Stories/recorder/ButtonWithCounterView.java` | K13, K14 | The shared button changed. The field and the constructor must both use `radiusDp`. |
329−
330−All Java paths above start with `TMessagesProj/src/main/java/org/telegram/`, and the `.attheme` files are in `TMessagesProj/src/main/assets/`.
331−
332−The stack's new files never conflict: they do not exist in Forkgram.
333−
334−### 5.4 Finish and hand it back
335−
336−When the rebase is done, check the result. These are the checks the CI runs, plus stricter versions:
337−
338−```sh
339−bash multigram/tools/check.sh
340−python3 multigram/tools/palette_fix_check.py --base origin/forkgram
341−python3 multigram/tools/random_style_check.py --base origin/forkgram
342−python3 multigram/tools/style_knobs_check.py --base origin/forkgram
343−git grep -n -e 'multigram\.Rebrand\.' -e 'multigram/rebrand/rebrand.gradle' -- TMessagesProj TMessagesProj_App
344−git grep -n '"forkCustomTitle"' -- TMessagesProj/src/main/java
345−cmp multigram/palette-fix/overlay_A.json multigram/tools/sim/palette-fix/overlay_A.json && echo "overlay copies identical"
346−```
347−
348−- `check.sh` must end with `== all MultiGram checks passed` (about 1 to 3 minutes; section 10.2 explains each part).
349−- Each of the three Python checks must print one line ending in `OK`. With `--base`, `random_style_check.py` and `style_knobs_check.py` also require the `MultiGram:` marker on every non-blank line the stack adds to their hooked files, and `palette_fix_check.py` checks that `ThemeColors.java` and the `.attheme` files change only the keys of overlay A.
350−- The first `git grep` must print 5 lines (hooks R1 to R5). Only R5 is also checked by a script (the rebrand self-test in `check.sh`).
351−- The second `git grep` lists every use of the title setting. On 4780eb095b it prints 4 lines. Each line that reads the setting must pass its default through `Rebrand.defaultTitle(` or use `defaultValue` (R2 to R4). A doubled line here means a conflict was fixed by keeping both sides.
352−- The `cmp` must print `overlay copies identical`. If it does not, copy `multigram/palette-fix/overlay_A.json` over the other copy and fold that into "Add the style table and its generator" (6.3).
353−
354−If the conflict was in `ThemeColors.java`, an `.attheme` file or `Theme.java`, also re-derive the palette values (8.2.5).
355−
356−Then push, and let the sync compile and promote your branch:
357−
358−```sh
359−git push --force origin multigram-next
360−```
361−
362−Open **Actions > Sync with Forkgram > Run workflow**, tick **use_next**, and run it. If the compile passes, `multigram` moves to your branch and the issue closes.
363−
364−### 5.5 Leaving a commit out
365−
366−If Forkgram now does what a MultiGram commit did, run `git rebase --skip` when git stops at that commit. The commit is then left out. Tick **allow_drop** as well as **use_next** when you run the workflow, or it refuses a branch that lacks a commit.
367−
368−The automatic replay drops a commit by itself when it becomes empty because Forkgram took the same change. The closing note on the issue lists such commits.
369−
370−### 5.6 rerere: git's memory of your fixes
371−
372−With `rerere.enabled`, git records how you fixed each conflict. If you redo the same rebase in the same clone, git fills in the same fix. It still stops, and you still run `git add` and `git rebase --continue`. To have git stage its replayed fixes by itself, also run `git config rerere.autoUpdate true`.
373−
374−rerere only knows the fixes recorded in the clone where you made them. The scheduled sync starts from a fresh clone every time. So it reports the same conflict again until `multigram` carries your fix. That is why you push `multigram-next` and run **use_next**.
375−
376−### 5.7 Using patches instead of a rebase
377−
378−You can also export the stack as patch files and apply them to any Forkgram checkout. From a checkout of `main`:
379−
380−```sh
381−git fetch origin
382−bash scripts/export-patches.sh --base origin/forkgram --head origin/multigram
383−```
384−
385−It writes six numbered `.patch` files and `APPLY.txt` to the folder `multigram-patches/`. `APPLY.txt` says how to clone the matching Forkgram release and apply them with `git am -3`. The `-3` lets git merge like a rebase when the text around a hook changed.
386−
387−When a patch conflicts, git stops. The markers read `<<<<<<< HEAD` (Forkgram) and `>>>>>>> <commit subject>`. Fix the file as in 5.2, then:
388−
389−```sh
390−git add <file>
391−git am --continue
392−```
393−
394−Other commands: `git am --skip` leaves a patch out, `git am --abort` goes back, and `git am --show-current-patch=diff` shows the patch that failed. To export again into the same folder, add `--force` to `export-patches.sh`.
395−
396−A shallow clone (`--depth 1`) of a newer Forkgram release lacks the old files that `-3` needs. Git then fails with `Repository lacks necessary blobs to fall back on 3-way merge.` Fetch the release the patches were made on into the same clone, and start again:
397−
398−```sh
399−git am --abort
400−git fetch --depth 1 origin tag 12.10.6.0
401−git am -3 /path/to/multigram-patches/*.patch
402−```
403−
404−The tag is the Forkgram version the patches were exported from, plus `.0`.
405−
406−## 6. Fixing a compile failure
407−
408−Use this section when the sync issue says "the compile check failed". The stack applied, and the candidate is on `multigram-next`.
409−
410−### 6.1 Find what failed
411−
412−Open the run page linked from the issue. In the job **Compile Java and Kotlin**, see which step failed:
413−
414−- **Compile**: a Java or Kotlin error. Read on.
415−- **Run MultiGram tool checks**: `check.sh` failed. See 10.2.
416−- A step failed with a network or runner error: press **Re-run failed jobs**. Nothing else is needed.
417−
418−From a terminal (not run while writing this guide):
419−
420−```sh
421−gh run view <run-id> -R iko-soy/Multigram --log-failed
422−```
423−
424−The Java compiler prints each error as `<file>:<line>: error: <message>`. Search the log for `error:`.
425−
426−### 6.2 Common errors
427−
428−| The error mentions | Likely cause | Fix |
429−|---|---|---|
430−| `cannot find symbol` and `isMonet()` in `PaletteFix.java` or `RandomStyle.java` | Forkgram renamed or removed `Theme.ThemeInfo.isMonet()`. | Use the new name, or drop the Monet condition. The calls are at `PaletteFix.java` lines 165 and 225, and `RandomStyle.java` lines 449 and 624. |
431−| `cannot find symbol` and a `key_...` name | Telegram renamed or removed a colour key in `Theme.java`. | Rename it everywhere MultiGram names it, with and without the `key_` prefix (see below the table). The `git grep` below the table finds every place. Most keys of overlay A are in `PaletteFix.java`, both `overlay_A.json` copies, `overlay_B.json` and the simulator (`sim/readability.py`, `sim/canvas.py`, two in `sim/engine.py`). A few keys are also in `style_table.py`, `style_knobs_check.py`, `RandomStyleUi.java` or a README. Fix the simulator first: until then `canvas.py --build` stops with a Python `KeyError` naming the old key. Then re-derive the palette (8.2.5). |
432−| `reference to getColor is ambiguous` in `PaletteFix.java` | Upstream added another `Theme.getColor` with three arguments. | Change `Theme.getColor(key, null, true)` at `PaletteFix.java` line 316 to `Theme.getColor(key, (boolean[]) null, true)`. |
433−| `getAccent`, `saveThemeAccents`, `deleteThemeAccent` or a `ThemeAccent` field in `RandomStyle.java` | Telegram changed the accent API. | Read the new methods and adjust `RandomStyle` (8.3.6). |
434−| `SharedConfig.bubbleRadius`, `useThreeLinesLayout` or `setUseThreeLinesLayout` in `StyleKnobs.java` | Telegram renamed a chat setting. | Use the new names (8.4.6). |
435−| `duplicate case label` in `ThemeActivity.java` | Upstream now binds one of the three knob row types itself. | Call `bindStyleKnobRow` from upstream's `case` and remove hook K17's line. |
436−| `cannot find symbol` and `DEFAULT` in `Rebrand.java` | Forkgram renamed `LauncherIconController.LauncherIcon.DEFAULT`. | Point `Rebrand.filterLauncherIcons` at the new default icon entry (R1). |
437−| `cannot find symbol` for a variable used in a hook line, such as `applyingTheme`, `accent` or `sparseIntArray` | Upstream renamed a local variable or field. | Use the new name in the hook line. Keep the `// MultiGram:` comment. If a check script matches that line exactly, update its pattern too. |
438−| `cannot find symbol` for any other method, class or field, in a file under `org/telegram/messenger/multigram/` | Upstream renamed or removed a name that a new file uses. The "Upstream names it relies on" columns of 8.1.2, 8.2.2, 8.3.2 and 8.4.2 list these names. | Find the new name, and fix every use (see below the table). `check.sh` does not find this; only the compile does. |
439−| A Gradle message that starts with `[rebrand]` | `rebrand.gradle` found a problem. | See the list of messages in 8.1.5. |
440−| A Gradle error that the task `buildNativeDeps` does not exist | Forkgram renamed or removed its native build task. The compile command skips it with `-x :TMessagesProj:buildNativeDeps`. | Update `.github/workflows/build.yml` on `main`. |
441−
442−**When upstream renamed a name.** To see what became of it, search the upstream changes since the snapshot `multigram` is built on. The first command below prints each changed line that holds the old name, with three lines around it, so the new line next to a removed one shows too. The second lists every place MultiGram uses the old name, in the new files and the tools:
443−
444−```sh
445−git diff $(git merge-base origin/multigram origin/forkgram) origin/forkgram -- TMessagesProj/src/main/java | grep -C 3 '<old name>'
446−git grep -n -w '<old name>' -- TMessagesProj/src/main/java/org/telegram/messenger/multigram multigram
447−```
448−
449−For a colour key, search for both spellings, as in `git grep -n -w -e key_chat_inViews -e chat_inViews -- TMessagesProj/src/main/java/org/telegram/messenger/multigram multigram`: the Java code uses the `key_` prefix, the theme files and some tools do not.
450−
451−A rename can hit files of several stack commits. For example, a rename of `Theme.getActiveTheme()` hits `PaletteFix.java` (5b53859682) and `RandomStyle.java` and `RandomStyleUi.java` (27d32d57b4). Fold each file into its own commit (6.3).
452−
453−### 6.3 Fold the fix into the commit it belongs to
454−
455−The fix must go into the stack commit it belongs to, not on top as a seventh commit. A "fixup" commit and an automatic rebase do that.
456−
457−**1. Get the branch.** Coming from 6.1 (a failed compile on Actions), start from the candidate the sync pushed:
458−
459−```sh
460−git fetch origin
461−git switch -C multigram-next origin/multigram-next
462−```
463−
464−Skip these two commands if you are already on your own `multigram-next` (from section 5, or from 7.5). `git switch -C` would replace your local branch, and everything on it you have not pushed, with the remote one.
465−
466−**2. Find the commit each change belongs to.** This lists the six commits with their short hashes:
467−
468−```sh
469−git log --oneline origin/forkgram..multigram-next
470−```
471−
472−Each file, and each line, belongs to the commit that added it. For a file the stack adds: `git log --format='%h %s' --diff-filter=A origin/forkgram..multigram-next -- <file>`. For line `n` of any file: `git blame -s -L n,n HEAD -- <file>` (with `HEAD`, it reads the committed file, so it also works after you have edited the line). The tables in 3.1 and 7.2 help too.
473−
474−A fix can belong to several commits. For example, a rename in upstream code can hit `PaletteFix.java` (5b53859682) and `RandomStyle.java` (27d32d57b4).
475−
476−**3. Make one fixup commit per stack commit.** Name the files. Do not use `git commit -a`: it puts every edited file into one fixup, so changes land in the wrong commit, and the rebase can stop with the `CONFLICT (modify/delete)` described at the end of this step. The sync issue and `README.md` on `main` give the same per-commit command.
477−
478−```sh
479−git commit --fixup=<hash of commit A> -- <files of commit A>
480−git commit --fixup=<hash of commit B> -- <files of commit B>
481−GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash origin/forkgram
482−```
483−
484−If one file needs changes for two commits (`Theme.java` holds hooks of three), edit the lines of one commit, run its `git commit --fixup`, then edit the lines of the next.
485−
486−`--fixup` makes a commit titled `fixup! <subject>`. `--autosquash` melts it into the commit with that subject. `GIT_SEQUENCE_EDITOR=:` accepts the plan without opening an editor. Afterwards the branch has six commits again, with the same subjects and authors: `git log --oneline origin/forkgram..multigram-next` shows them.
487−
488−If the rebase stops with `CONFLICT (modify/delete): <file> deleted in HEAD and modified in ... (fixup! ...)`, a fixup holds a file that its commit does not have yet. Run `git rebase --abort`, then `git reset HEAD~1` for one fixup commit (`HEAD~2` for two, and so on). That removes the fixup commits and keeps your edits in the files. Then make the fixups again, one per commit.
489−
490−**4. Hand it back.** Coming from 7.5, go on with 7.6 instead. Otherwise push:
491−
492−```sh
493−git push --force origin multigram-next
494−```
495−
496−Then open **Actions > Sync with Forkgram > Run workflow** and tick **use_next**.
497−
498−### 6.4 Compiling locally
499−
500−This is what the CI runs. It was not run while writing this guide, because it needs JDK 17 and the Android SDK:
501−
502−```sh
503−git submodule update --init --depth 1 TMessagesProj/lib/jlatexmath TMessagesProj_Modules/media
504−./gradlew --no-daemon --stacktrace -x :TMessagesProj:buildNativeDeps :TMessagesProj:compileReleaseJavaWithJavac :TMessagesProj_App:compileAfatReleaseJavaWithJavac
505−bash multigram/tools/check.sh
506−```
507−
508−Gradle finds the SDK through `ANDROID_HOME` or a `local.properties` file with `sdk.dir=<path>`. The CI also appends `org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g` to `gradle.properties`. Do that if Gradle runs out of memory, but do not commit it.
509−
510−## 7. Re-applying the stack by hand
511−
512−Use this section when a rebase is not practical: many conflicts, or files that Forkgram moved or rewrote. You start from the new snapshot and add MultiGram's changes again.
513−
514−The result must again be six commits with the same subjects and authors. Then the sync's **use_next** accepts it.
515−
516−If a rebase from section 5 is still running, stop it first with `git rebase --abort`: git refuses to switch branches during a rebase.
517−
518−### 7.1 Get the base
519−
520−The new Forkgram release must be on `forkgram` first. The sync pushes the new snapshot even when the replay conflicts, so after a sync run it is there. If no sync has run, run it locally from a checkout of `main` (4.4) with `--push`.
521−
522−Then:
523−
524−```sh
525−git fetch origin
526−git switch -C multigram-next origin/forkgram
527−OLD=origin/multigram
528−OLDSNAP=$(git merge-base $OLD origin/forkgram)
529−git log -1 --format=%B origin/forkgram
530−```
531−
532−- `OLD` is the stack you re-apply. A tag `multigram-before-<version>` works too.
533−- `OLDSNAP` is the old snapshot that `OLD` sits on.
534−- The last command shows which Forkgram release you are on.
535−- `git switch -C` resets a local `multigram-next`, as in 5.1.
536−
537−The next steps use these two shell variables. If you open a new terminal, set them again.
538−
539−### 7.2 Copy all new files
540−
541−One command copies all 37 files the stack adds, as they are on `OLD`:
542−
543−```sh
544−git checkout $OLD -- multigram TMessagesProj/src/main/java/org/telegram/messenger/multigram \
545− TMessagesProj/src/main/res/values/multigram_rebrand.xml TMessagesProj/src/main/res/values/multigram_strings.xml \
546− TMessagesProj/src/main/assets/multigram_styles.bin
547−git reset -q
548−```
549−
550−`git reset -q` un-stages the files but leaves them in place, so the checks of 7.4 can run from the start. Do not commit these copies: they are the versions of `OLD`, and 7.3 copies each commit's own files again at that commit's version. `git status --short` now shows 5 untracked entries: the 2 folders and the 3 single files.
551−
552−None of these paths exists in Forkgram 12.10.6. If a future Forkgram adds a file with one of these names, `git checkout` would overwrite it. This command must print nothing:
553−
554−```sh
555−git ls-tree -r --name-only origin/forkgram -- multigram TMessagesProj/src/main/java/org/telegram/messenger/multigram TMessagesProj/src/main/res/values/multigram_rebrand.xml TMessagesProj/src/main/res/values/multigram_strings.xml TMessagesProj/src/main/assets/multigram_styles.bin
556−```
557−
558−The 37 files:
559−
560−| # | Path | Added by | Section |
561−|---|---|---|---|
562−| 1 | `TMessagesProj/src/main/java/org/telegram/messenger/multigram/Rebrand.java` | d457ac74ae | 8.1 |
563−| 2 | `TMessagesProj/src/main/res/values/multigram_rebrand.xml` | d457ac74ae | 8.1 |
564−| 3 | `multigram/rebrand/.gitignore` | d457ac74ae | 8.1 |
565−| 4 | `multigram/rebrand/README.md` | d457ac74ae | 8.1 |
566−| 5 | `multigram/rebrand/generate_rebrand.py` | d457ac74ae | 8.1 |
567−| 6 | `multigram/rebrand/rebrand.gradle` | d457ac74ae | 8.1 |
568−| 7 | `multigram/rebrand/selftest.py` | d457ac74ae | 8.1 |
569−| 8 | `TMessagesProj/src/main/java/org/telegram/messenger/multigram/PaletteFix.java` | 5b53859682 | 8.2 |
570−| 9 | `multigram/palette-fix/README.md` | 5b53859682 | 8.2 |
571−| 10 | `multigram/palette-fix/overlay_A.json` | 5b53859682 | 8.2 |
572−| 11 | `multigram/tools/palette_fix_check.py` | 5b53859682 | 8.2 |
573−| 12 | `TMessagesProj/src/main/assets/multigram_styles.bin` | cddd62df98 | 8.3 |
574−| 13 | `multigram/tools/.gitignore` | cddd62df98 | 8.3 |
575−| 14 | `multigram/tools/README.md` | cddd62df98 | 8.3 |
576−| 15 | `multigram/tools/check.sh` | cddd62df98 | 8.3 |
577−| 16 | `multigram/tools/check_style_table.py` | cddd62df98 | 8.3 |
578−| 17 | `multigram/tools/make_style_table.py` | cddd62df98 | 8.3 |
579−| 18 | `multigram/tools/sim/README.md` | cddd62df98 | 8.3 |
580−| 19 | `multigram/tools/sim/canvas.py` | cddd62df98 | 8.3 |
581−| 20 | `multigram/tools/sim/detmath.py` | cddd62df98 | 8.3 |
582−| 21 | `multigram/tools/sim/detmath_tables.py` | cddd62df98 | 8.3 |
583−| 22 | `multigram/tools/sim/engine.py` | cddd62df98 | 8.3 |
584−| 23 | `multigram/tools/sim/palette-fix/overlay_A.json` | cddd62df98 | 8.3 |
585−| 24 | `multigram/tools/sim/palette-fix/overlay_B.json` | cddd62df98 | 8.3 |
586−| 25 | `multigram/tools/sim/readability.py` | cddd62df98 | 8.3 |
587−| 26 | `multigram/tools/sim/style_sim.py` | cddd62df98 | 8.3 |
588−| 27 | `multigram/tools/sim/tgsrc.py` | cddd62df98 | 8.3 |
589−| 28 | `multigram/tools/style_table.py` | cddd62df98 | 8.3 |
590−| 29 | `TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyle.java` | 27d32d57b4 | 8.3 |
591−| 30 | `TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyleUi.java` | 27d32d57b4 | 8.3 |
592−| 31 | `TMessagesProj/src/main/java/org/telegram/messenger/multigram/StyleTable.java` | 27d32d57b4 | 8.3 |
593−| 32 | `TMessagesProj/src/main/res/values/multigram_strings.xml` | 27d32d57b4 | 8.3 |
594−| 33 | `multigram/random-style/README.md` | 27d32d57b4 | 8.3 |
595−| 34 | `multigram/tools/random_style_check.py` | 27d32d57b4 | 8.3 |
596−| 35 | `TMessagesProj/src/main/java/org/telegram/messenger/multigram/StyleKnobs.java` | 3b941c01de | 8.4 |
597−| 36 | `multigram/style-knobs/README.md` | 3b941c01de | 8.4 |
598−| 37 | `multigram/tools/style_knobs_check.py` | 3b941c01de | 8.4 |
599−
600−### 7.3 Put the hooks back, commit by commit
601−
602−Work through the six commits in order. For each one: copy the commit's own files at that commit's version, put its hooks into the upstream files, and commit with the same message and author.
603−
604−**The quick way: a loop.** This loop does that for every commit. For each upstream file it tries the commit's change with `git apply -3`, which merges the way a rebase does. It stops at the first commit with a file it cannot patch cleanly, and names the files.
605−
606−```sh
607−START=$OLDSNAP
608−for C in $(git rev-list --reverse $START..$OLD); do
609− echo "== $(git log -1 --format='%h %s' $C)"
610− new=$(git diff-tree --no-commit-id -r --name-only --diff-filter=A $C)
611− own=$(git diff-tree --no-commit-id -r --name-only --diff-filter=M $C -- multigram TMessagesProj/src/main/java/org/telegram/messenger/multigram)
612− up=$(git diff-tree --no-commit-id -r --name-only --diff-filter=M $C -- . ':!multigram' ':!TMessagesProj/src/main/java/org/telegram/messenger/multigram')
613− if [ -n "$new$own" ]; then git checkout $C -- $new $own; fi
614− todo=
615− for f in $up; do
616− git diff $C^ $C -- $f | git apply -3 --index || todo="$todo $f"
617− done
618− if [ -n "$todo" ]; then
619− echo "STOPPED at $C. Put the hooks of these files in by hand:$todo"
620− break
621− fi
622− git commit --quiet -C $C
623−done
624−```
625−
626−`git commit -C $C` reuses the commit's message, author and date. That is what **use_next** matches on.
627−
628−This was tested on 2026-09-27 against the made-up Forkgram release of 5.2. The loop stopped at 27d32d57b4 on `MessagesController.java`. After the fix below, all six new commits had exactly the same content as the rebase in section 5 gave.
629−
630−**When the loop stops:**
631−
632−1. Look at each file it names.
633− - If it contains conflict markers, fix it as in 5.2. Here the markers read `<<<<<<< ours` (new Forkgram) and `>>>>>>> theirs` (old Forkgram with the hook).
634− - If git printed `Resolved '<file>' using previous resolution`, rerere (5.6) has already put in a fix you made earlier in this clone, for example during a section 5 attempt. The file has no markers but still counts as unmerged. Read the fix with `git diff HEAD -- <file>` before you `git add` it. rerere repeats values, not the `overlay_A.json` that went with them, so if the file is `ThemeColors.java` or an `.attheme` file, run `python3 multigram/tools/palette_fix_check.py --base origin/forkgram` before you commit. If it reports `expected <value>`, write the value it expects: that is the value of this commit's `overlay_A.json`. 7.5 re-derives the palette afterwards and adopts new values then.
635− - If it is unchanged, git could not place the change at all. Put the hooks in by hand, with section 8. `git show $C -- <file>` shows what the commit changed.
636−2. Commit the stopped commit:
637−
638− ```sh
639− git add <the files>
640− git commit -C $C
641− ```
642−
643−3. Continue after it. Set `START` to the commit that stopped, and paste the loop again from its second line:
644−
645− ```sh
646− START=$C
647− ```
648−
649−**By hand, without the loop.** First list the six commits you re-apply, with their hashes:
650−
651−```sh
652−git log --reverse --format='%h %s' $OLDSNAP..$OLD
653−```
654−
655−Use these hashes as `<commit>` below, not the hashes printed in this guide. Those are the commits of `multigram` 4780eb095b, and every sync rebases the stack, so after the next sync they are old versions (still reachable through the `multigram-before-<version>` tags, so git would not complain). Then, for each commit in that order (the table in 7.7 shows what each one holds):
656−
657−1. List what it touches: `git diff-tree --no-commit-id -r --name-status <commit>`.
658−2. Copy the MultiGram files it adds or changes, at its version:
659−
660− ```sh
661− git checkout <commit> -- $(git diff-tree --no-commit-id -r --name-only <commit> -- multigram TMessagesProj/src/main/java/org/telegram/messenger/multigram TMessagesProj/src/main/res/values/multigram_rebrand.xml TMessagesProj/src/main/res/values/multigram_strings.xml TMessagesProj/src/main/assets/multigram_styles.bin)
662− ```
663−
664− This copies only the paths the commit has. Naming a path by hand that the commit does not have yet (say `multigram_styles.bin` for d457ac74ae) makes `git checkout` copy nothing at all.
665−3. Put its hooks into the upstream files. Section 8 lists them in order. Mind the four files without a final newline (5.2).
666−4. `git add` the upstream files and run `git commit -C <commit>`.
667−
668−### 7.4 Use the checks as a to-do list
669−
670−The check scripts name every missing or misplaced hook. Run them at any point:
671−
672−```sh
673−python3 multigram/tools/palette_fix_check.py --base origin/forkgram
674−python3 multigram/tools/random_style_check.py --base origin/forkgram
675−python3 multigram/tools/style_knobs_check.py --base origin/forkgram
676−git grep -n -e 'multigram\.Rebrand\.' -e 'multigram/rebrand/rebrand.gradle' -- TMessagesProj TMessagesProj_App
677−cmp multigram/palette-fix/overlay_A.json multigram/tools/sim/palette-fix/overlay_A.json && echo "overlay copies identical"
678−```
679−
680−With all 37 files copied but no hooks in place, a test tree gave 59, 27 and 35 problems from the three checks, and the rebrand self-test failed one check (`TMessagesProj_App/build.gradle applies rebrand.gradle`). When everything is back:
681−
682−- each check prints a single line ending in `OK`;
683−- the `git grep` prints 5 lines, one for each of R1 to R5;
684−- the `cmp` prints `overlay copies identical` (see 5.4 if not).
685−
686−Each problem line says what is wrong, for example `the upload guard is not the first statement of saveThemeToServer`.
687−
688−### 7.5 Regenerate and check derived data
689−
690−Two things are derived from upstream sources and may need a refresh. Do this after 7.3, on your local `multigram-next`:
691−
692−1. **The palette values** (overlay A). Re-derive them from the new plain snapshot with the simulator (8.2.5, step 2). If they change, adopt them as "To adopt a new overlay" in 8.2.5 says. Its last step names the commit each file goes into.
693−2. **The style table** (`multigram_styles.bin`). Run `bash multigram/tools/check.sh`. If it asks for a new table, run `python3 multigram/tools/make_style_table.py` (8.3.5). The new file goes into "Add the style table and its generator".
694−
695−To fold a change into its commit, follow 6.3 from its step 2. Skip its step 1: your six commits exist only on your local branch until 7.6, and `git switch -C multigram-next origin/multigram-next` would throw them away.
696−
697−Nothing else is derived. The rebrand output is generated at build time and never committed. The knob values are computed on the device.
698−
699−### 7.6 Compile
700−
701−Push the branch and run the compile check on it (10.1), or compile locally (6.4):
702−
703−```sh
704−git push --force origin multigram-next
705−```
706−
707−### 7.7 Check the six commits
708−
709−The new commits must match the old ones in author, date and subject:
710−
711−```sh
712−diff <(git log --reverse --format='%an %ad %s' $OLDSNAP..$OLD) <(git log --reverse --format='%an %ad %s' origin/forkgram..multigram-next) && echo "same six commits"
713−```
714−
715−| # | Subject (keep it exactly) | Hooks | Section |
716−|---|---|---|---|
717−| 1 | Add the rebrand toolkit | R1–R5 | 8.1 |
718−| 2 | Fix palette contrast for generated accents | P1–P18 | 8.2 |
719−| 3 | Add the style table and its generator | none (new files only) | 8.3 |
720−| 4 | Give each install its own random style | S1–S15 | 8.3 |
721−| 5 | Randomise shapes and chat list layout per install | K1–K17 (K18–K23 come with the copied files) | 8.4 |
722−| 6 | Run the rebrand self-test in the CI checks | none (K24 comes with the copied `check.sh`) | 8.4 |
723−
724−### 7.8 Hand it to the sync
725−
726−Push (7.6), then run **Actions > Sync with Forkgram** with **use_next**.
727−
728−If **use_next** refuses with "multigram-next does not carry N of multigram's commit(s)", then someone pushed to `multigram` after you started, or a subject or author changed. Add the missing commit with `git cherry-pick <commit>`, or tick **allow_drop** if you left it out on purpose.
729−
730−## 8. The changes, one by one
731−
732−Each part below has the same six subsections: what and why, new files, hooks, data, how to regenerate and verify, and gotchas.
733−
734−### 8.1 Rebrand toolkit (d457ac74ae)
735−
736−#### 8.1.1 What and why
737−
738−The rebrand toolkit gives a build its own identity: application id, app name, launcher, splash and notification icons, signing key, and Android account type. It is meant for builds you give to other people. A stock build (no rebrand) behaves exactly like Forkgram.
739−
740−It works at build time:
741−
742−1. You run `python3 multigram/rebrand/generate_rebrand.py --seed <any text>`. The seed makes the identity repeatable.
743−2. The generator writes a resource overlay to `multigram/rebrand/generated/res` and settings to `multigram/rebrand/rebrand.properties`. Both are git-ignored.
744−3. It also edits three upstream files in place, because the account type is a Java literal that no overlay can replace: `ContactsController.java`, `res/xml/auth.xml` and `res/xml/sync_contacts.xml`. It keeps backups and adds a marker comment. These edits are never committed.
745−4. `rebrand.gradle` reads the properties and sets the application id, signing, version and resource folders.
746−5. `python3 multigram/rebrand/generate_rebrand.py --clean` puts everything back byte for byte.
747−
748−The committed upstream changes are small: four Java hooks of one code line each (R2 and R3 also put a comment line above theirs) and one Gradle line. Three Java hooks replace Forkgram's default chat list title "Fork Client" with the app's own name in a rebranded build. One hides the other launcher icons.
749−
750−#### 8.1.2 New files
751−
752−| Path | Purpose | Upstream names it relies on |
753−|---|---|---|
754−| `TMessagesProj/src/main/java/org/telegram/messenger/multigram/Rebrand.java` | The only runtime code. `isActive()` reads the flag `multigram_rebrand_active`. `defaultTitle(stock)` returns the app's own `AppName` in a rebranded build and `stock` otherwise. `filterLauncherIcons(list)` keeps only the default icon in a rebranded build. | `ApplicationLoader.applicationContext`, `FileLog.e(Throwable)`, `R.string.AppName`, `LauncherIconController.LauncherIcon.DEFAULT` |
755−| `TMessagesProj/src/main/res/values/multigram_rebrand.xml` | Declares `bool/multigram_rebrand_active` as false. The generated overlay sets it to true. | None. It must stay in the `TMessagesProj` library, because `Rebrand.java` compiles against that module's `R`. |
756−| `multigram/rebrand/.gitignore` | Keeps `generated/`, `rebrand.properties` (it holds key passwords) and `__pycache__/` out of git. | None |
757−| `multigram/rebrand/README.md` | User documentation: quick start, what changes, icons, account type, signing and updates. | Documentation only. It names Forkgram's icons, strings and ids; correct it if they change. |
758−| `multigram/rebrand/generate_rebrand.py` | The generator (Python standard library, plus `keytool` for the key). Makes the identity from the seed, writes the overlay and properties, patches the account type, and `--clean` undoes it all. | The resource folders `TMessagesProj/src/*/res` and `TMessagesProj_App/src/*/res`; the icon, splash and string names in 8.1.4; the account type in the three files above |
759−| `multigram/rebrand/rebrand.gradle` | Gradle glue, applied by hook R5. In stock mode it only fails the build if a rebrand marker was left behind. In rebrand mode it sets the id, signing, version and resources, and feeds the new app name to Telegram's string tasks. | The `android {}` block of `TMessagesProj_App/build.gradle`; the stock ids `org.forkclient.messenger` and `org.forkgram.messenger`; the `generate*TelegramStrings` tasks of class `org.telegram.tasks.TelegramStringsTask` with the properties `stringsXml` and `localizationFiles` |
760−| `multigram/rebrand/selftest.py` | End-to-end test without an Android SDK. It copies the tracked sources into a throwaway git repository, runs the generator with two seeds, checks every output, and checks that `--clean` restores the tree. | `git`; the exact R5 line; the three account-type files; exactly 5 account-type literals in `ContactsController.java` |
761−
762−#### 8.1.3 Hooks
763−
764−##### R1. Hide the other launcher icons in a rebranded build
765−
766−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Cells/AppIconsSelectorCell.java`
767−
768−**Where:** Class `AppIconsSelectorCell`, method `updateIconsVisibility()`. The new line goes right after the icon list is filled. In Forkgram 12.10.6 the text below starts at line 153.
769−
770−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
771−
772−```java
773− availableIcons.addAll(Arrays.asList(LauncherIconController.LauncherIcon.values()));
774−```
775−
776−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
777−
778−```diff
779− private void updateIconsVisibility() {
780− availableIcons.clear();
781− availableIcons.addAll(Arrays.asList(LauncherIconController.LauncherIcon.values()));
782−+ org.telegram.messenger.multigram.Rebrand.filterLauncherIcons(availableIcons); // MultiGram: a rebranded build offers only its own icon
783− if (MessagesController.getInstance(currentAccount).premiumFeaturesBlocked()) {
784− for (int i = 0; i < availableIcons.size(); i++) {
785− if (availableIcons.get(i).premium) {
786−```
787−
788−**Why:** Chat Settings has an app icon row that lists every launcher icon. The other icons are Forkgram's and Telegram's logos. In a rebranded build, `Rebrand.filterLauncherIcons` keeps only `DEFAULT`, the icon whose files the rebrand replaces. In a stock build it does nothing.
789−
790−**If the code moved:** Put the call right after the list that feeds the picker is filled from `LauncherIconController.LauncherIcon.values()`, and before anything reads the list (the premium filter, `notifyDataSetChanged`, the scroll loop). Find candidates with `git grep -n "LauncherIcon.values()" -- TMessagesProj/src/main/java`. Leave two other users alone: `LauncherIconController` (it switches every icon alias on and off, so it must see all of them) and `PremiumAppIconsPreviewView` (the Premium screen, left as it is on purpose). If Forkgram renames `DEFAULT`, or points another entry at the `icon_01_*` files, update `Rebrand.filterLauncherIcons`.
791−
792−
793−##### R2. Chat list title: default to the rebranded app name
794−
795−**File:** `TMessagesProj/src/main/java/org/telegram/ui/DialogsActivity.java`
796−
797−**Where:** Class `DialogsActivity`, method `createView(Context)`, in the last `else` branch that sets up the main chat list title (no folder, no community). In Forkgram 12.10.6 the text below starts at line 3512.
798−
799−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
800−
801−```java
802− actionBar.setTitle(MessagesController.getGlobalMainSettings().getString("forkCustomTitle", "Fork Client"));
803−```
804−
805−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
806−
807−```diff
808− SpannableStringBuilder ssb = new SpannableStringBuilder(getString(R.string.AppName));
809− ssb.setSpan(new ImageSpan(logoDrawable), 0, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
810− actionBar.setTitle(ssb, statusDrawable);
811−- actionBar.setTitle(MessagesController.getGlobalMainSettings().getString("forkCustomTitle", "Fork Client"));
812−+ // MultiGram: a rebranded build's default title is its own app name.
813−+ actionBar.setTitle(MessagesController.getGlobalMainSettings().getString("forkCustomTitle", org.telegram.messenger.multigram.Rebrand.defaultTitle("Fork Client")));
814− actionBar.setTitleLongClickListener(v -> {
815− boolean mainTabsHidden = !UserConfig.getInstance(currentAccount).getMainTabsHiddenFork();
816− UserConfig.getInstance(currentAccount).setMainTabsHiddenFork(mainTabsHidden);
817−```
818−
819−**Why:** Forkgram shows the setting `forkCustomTitle` as the chat list title, with the fixed default "Fork Client". With the hook, a rebranded build shows its own app name when the user has not set a title. A stock build still shows "Fork Client". The comment sits on its own line above the hook.
820−
821−**If the code moved:** Find every place that reads the title setting: `git grep -n '"forkCustomTitle"' -- TMessagesProj/src/main/java`. Wrap each literal default in `org.telegram.messenger.multigram.Rebrand.defaultTitle(<the stock text>)`. If Forkgram changes the default text, pass the new text, so stock builds stay identical to Forkgram. If Forkgram drops the custom title and shows `R.string.AppName` as text, no hook is needed: the rebrand already renames `AppName`.
822−
823−**On plain Telegram (DrKLO):** Forkgram only. Plain Telegram has no `forkCustomTitle`: it draws the `telegram_logo_2` picture over `R.string.AppName` (the line just above the hook), so renaming `AppName` does not change what is shown. See section 9.
824−
825−
826−##### R3. Fork Client Settings: show the same default title
827−
828−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ForkSettingsActivity.java`
829−
830−**Where:** Class `ForkSettingsActivity`, method `fillSettings(ArrayList<UItem>)`: the "Custom title" row (`ID_CUSTOM_TITLE`). In Forkgram 12.10.6 the text below starts at line 547.
831−
832−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
833−
834−```java
835− items.add(UItem.asSettingsCell(ID_CUSTOM_TITLE, LocaleController.getString(R.string.EditAdminRank), prefs().getString("forkCustomTitle", "Fork Client")));
836−```
837−
838−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
839−
840−```diff
841− items.add(UItem.asButtonCheck(ID_HIDE_BOTTOM_BUTTON, LocaleController.getString(R.string.HideBottomButton), LocaleController.getString(R.string.HideBottomButtonInfo))
842− .setChecked(pref("hideBottomButton", false)).setMultiline(true));
843− }
844−- items.add(UItem.asSettingsCell(ID_CUSTOM_TITLE, LocaleController.getString(R.string.EditAdminRank), prefs().getString("forkCustomTitle", "Fork Client")));
845−+ // MultiGram: a rebranded build's default title is its own app name.
846−+ items.add(UItem.asSettingsCell(ID_CUSTOM_TITLE, LocaleController.getString(R.string.EditAdminRank), prefs().getString("forkCustomTitle", org.telegram.messenger.multigram.Rebrand.defaultTitle("Fork Client"))));
847− items.add(UItem.asShadow(null));
848−
849− items.add(UItem.asHeader(LocaleController.getString(R.string.AvatarShape)));
850−```
851−
852−**Why:** The row shows the current title as its value. Without a saved title, a rebranded build now shows its own app name here too, the same as the chat list. A stock build still shows "Fork Client".
853−
854−**If the code moved:** This is the settings row that shows the `forkCustomTitle` value. Wrap its literal default in `Rebrand.defaultTitle(<stock text>)`. It must agree with R2 and R4.
855−
856−**On plain Telegram (DrKLO):** Forkgram only: `ForkSettingsActivity.java` does not exist in plain Telegram.
857−
858−
859−##### R4. Custom title dialog: same default
860−
861−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ForkSettingsActivity.java`
862−
863−**Where:** Class `ForkSettingsActivity`, method `showCustomTitleDialog(View)`, first line. In Forkgram 12.10.6 the text below starts at line 1154.
864−
865−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
866−
867−```java
868− final String defaultValue = "Fork Client";
869−```
870−
871−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
872−
873−```diff
874− }
875−
876− private void showCustomTitleDialog(View view) {
877−- final String defaultValue = "Fork Client";
878−+ final String defaultValue = org.telegram.messenger.multigram.Rebrand.defaultTitle("Fork Client"); // MultiGram: rebranded default
879− org.telegram.messenger.forkgram.ForkDialogs.createFieldAlert(
880− getContext(),
881− LocaleController.getString(R.string.EditAdminRank),
882−```
883−
884−**Why:** The dialog fills its text field with the saved title or `defaultValue`, and an empty answer saves `defaultValue`. In a rebranded build both now use the app's own name, so clearing the field never brings "Fork Client" back.
885−
886−**If the code moved:** Find the dialog that saves the title: `git grep -n 'putString("forkCustomTitle"' -- TMessagesProj/src/main/java`. Whatever default it shows or saves must come from `Rebrand.defaultTitle(<stock text>)`.
887−
888−**On plain Telegram (DrKLO):** Forkgram only.
889−
890−
891−##### R5. Apply the rebrand Gradle script
892−
893−**File:** `TMessagesProj_App/build.gradle`
894−
895−**Where:** The very end of the file, after the closing brace of the `android { }` block. In Forkgram 12.10.6 the text below starts at line 330.
896−
897−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
898−
899−```groovy
900− checkReleaseBuilds false
901− }
902−}
903−```
904−
905−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
906−
907−```diff
908− checkReleaseBuilds false
909− }
910− }
911−+
912−+// MultiGram: build-time rebrand, a no-op unless multigram/rebrand/rebrand.properties exists.
913−+apply from: "${rootDir}/multigram/rebrand/rebrand.gradle"
914−```
915−
916−**Why:** This connects the toolkit to the app module. It must come after the module's own `android { }` block, so that the rebrand's application id, signing, version and resource folder replace the module's values. Without `multigram/rebrand/rebrand.properties` (a stock build) the script only checks that no rebrand patch was left behind in three source files.
917−
918−**If the code moved:** Keep one `apply from:` line at the very end of the app module's build script, after `android { }`. If upstream adds lines after `android { }`, put this line after them. If the app module is renamed, or `settings.gradle` gets another app module, hook that module; the paths inside `rebrand.gradle` start at the repository root. `selftest.py` looks for this exact text.
919−
920−**On plain Telegram (DrKLO):** Conflicts: plain Telegram's file ends with `apply plugin: 'com.google.gms.google-services'`. Keep both lines.
921−
922−
923−#### 8.1.4 Data
924−
925−Nothing derived is committed. The generator looks for these upstream names. If Forkgram renames one, the generator or the self-test says so.
926−
927−| What | Names in Forkgram 12.10.6 | Where the name is set |
928−|---|---|---|
929−| App name strings | `AppName`, `AppNameBeta`, `AppNameFdroid` in `values/strings.xml` (lines 177 to 179) and 10 translations; also `AppNameBeta` ("Fork Client Test") in `TMessagesProj_App/src/forkTest/res/values/strings.xml`, which the generator scans too | `APP_NAME_STRINGS` in `generate_rebrand.py` |
930−| Icons | 12 entries: mipmap `ic_launcher`, `ic_launcher_round`, `icon_01_launcher`, `icon_01_launcher_round`, `icon_01_launcher_adaptive`, `icon_01_launcher_sa`, `icon_01_foreground_sa`; drawable `icon_01_background_sa`, `ic_launcher_dr`, `splash_fork_320`, `tg_splash_320`, `notification`. That is 54 files today. | `ICON_TARGETS` in `generate_rebrand.py` |
931−| Account type in Java | 5 literals `"org.telegram.messenger"` in `TMessagesProj/src/main/java/org/telegram/messenger/ContactsController.java` | `JAVA_EXPECTED_LITERALS` in `generate_rebrand.py`, and `== 5` at `selftest.py` line 270 |
932−| Account type in XML | `android:accountType="${applicationId}"`, once each in `res/xml/auth.xml` and `res/xml/sync_contacts.xml` | `STOCK_XML_ACCOUNT_TYPE` in `generate_rebrand.py` |
933−| Stock application ids | `org.forkclient.messenger`, and `org.forkgram.messenger` for F-Droid builds | `stockIds` in `rebrand.gradle` |
934−| Default chat list title | `"Fork Client"` | The argument of `Rebrand.defaultTitle(...)` in hooks R2, R3 and R4 |
935−
936−#### 8.1.5 Regenerate and verify
937−
938−**The self-test.** Run it from the root of a checkout that is not rebranded. It takes about 30 seconds, and needs `git` (and `keytool` for the signing checks):
939−
940−```sh
941−python3 multigram/rebrand/selftest.py
942−```
943−
944−It passes when no line starts with `FAIL` and the last line is `PASSED: 0 failure(s)` (exit status 0). Besides the `ok` lines it prints the throwaway folder, an indented "Rebrand identity" block per seed and blank lines. An excerpt of a passing run on 4780eb095b (the temporary path differs each time):
945−
946−```text
947−throwaway tree: /tmp/rebrand-selftest-n2jxoni1/tree
948−ok TMessagesProj_App/build.gradle applies rebrand.gradle
949−ok seed A: generator succeeds
950−ok seed A: no warnings
951−ok seed A: overlay covers all 54 upstream icon files in the same folders
952−ok seed A: account type dev.quietshoal.app in Java (5x) and both XML files
953−ok --clean: git status clean (incl. ignored files), files byte-identical
954−ok --clean twice is harmless
955−
956−PASSED: 0 failure(s)
957−```
958−
959−- A `FAIL` line ends the run with `FAILED: N failure(s)` and exit status 1. The line names the check.
960−- Exit status 2 with `This tree is rebranded; run generate_rebrand.py --clean first.` means the tree you ran it from has `generated/` or `rebrand.properties`.
961−- Without `keytool` it prints `note: keytool not found, signing checks skipped` just before the final `PASSED` or `FAILED` line.
962−- The counts (54 icon files and so on) come from the tree. They may change when Forkgram adds or drops icon sizes.
963−
964−**The hooks.** No script checks the four Java hooks. This must print 5 lines:
965−
966−```sh
967−git grep -n -e 'multigram\.Rebrand\.' -e 'multigram/rebrand/rebrand.gradle' -- TMessagesProj TMessagesProj_App
968−```
969−
970−On 4780eb095b they are `AppIconsSelectorCell.java:154`, `DialogsActivity.java:3513`, `ForkSettingsActivity.java:548` and `:1155`, and `TMessagesProj_App/build.gradle:335`.
971−
972−Every use of the title setting must pass through `Rebrand.defaultTitle`. This lists them:
973−
974−```sh
975−git grep -n '"forkCustomTitle"' -- TMessagesProj/src/main/java
976−```
977−
978−**A real identity, in a scratch worktree.** Never do this in the checkout you commit from (see 8.1.6):
979−
980−```sh
981−git fetch origin
982−git worktree add --detach /tmp/mg-rebrand origin/multigram
983−cd /tmp/mg-rebrand
984−python3 multigram/rebrand/generate_rebrand.py --seed patch-doc-demo
985−```
986−
987−On 2026-09-27 it printed, among other lines:
988−
989−```text
990− applicationId dev.cleargrove.im (Gradle adds the build type suffix, e.g. .beta)
991− app name Willowbirch
992− TMessagesProj/src/main/java/org/telegram/messenger/ContactsController.java: 5 literal(s)
993−```
994−
995−Success means: exit status 0, no `WARNING:` line, and exactly `5 literal(s)`. A line like `N literal(s) (expected 5: check the file)` is a problem, even without the word WARNING. `git status --porcelain` then shows the three patched files as modified, and `generated/` holds 74 files.
996−
997−To undo it and remove the worktree:
998−
999−```sh
1000−python3 multigram/rebrand/generate_rebrand.py --clean
1001−cd -
1002−git worktree remove /tmp/mg-rebrand
1003−```
1004−
1005−`--clean` prints `Rebrand removed: stock identity restored (3 source file(s) put back).` and `The generated signing key was deleted with it.` `cd -` goes back to the checkout you started from.
1006−
1007−**Gradle messages.** These come from reading `rebrand.gradle`, not from a build. A rebranded build prints lines that start with `[rebrand] :TMessagesProj_App:`. A stock build prints no `[rebrand]` line. The messages that point to a problem:
1008−
1009−| Message starts with | Kind | Meaning |
1010−|---|---|---|
1011−| `[rebrand] :TMessagesProj_App: no generate*TelegramStrings task found` | warning | Telegram's build code (`buildSrc`) changed, and the in-app strings keep the stock name. Update `rebrand.gradle`. |
1012−| `[rebrand] account type ... is not in place in ...` | error | A half-patched tree. Run the generator again, or `--clean`. |
1013−| `[rebrand] no active rebrand (multigram/rebrand/rebrand.properties), but these files still carry a rebrand's account type` | error | `rebrand.properties` was deleted without `--clean`. See 8.1.6. |
1014−| `[rebrand] signing keystore ... not found` | error | The key file named in `rebrand.properties` is missing. Put it back. |
1015−
1016−**On the APK and a device** (needs the Android build tools):
1017−
1018−- `aapt2 dump badging <file>.apk | grep -E '^package:|application-label'` shows the new package and label.
1019−- `apksigner verify --print-certs <file>.apk` shows the key fingerprint the generator printed.
1020−- On the device: the launcher, splash and notification icons are the generated ones; the chat list title is the app name; the app icon picker offers one icon; after login with contact sync, **Settings > Accounts** lists an account with the app's name.
1021−
1022−#### 8.1.6 Gotchas
1023−
1024−- **Never commit, rebase, pull or switch to a new Forkgram while a rebrand is active.** The three patched files show as modified. Run `--clean` first and generate again afterwards.
1025−- **`--clean` deletes `rebrand.properties` and the generated key.** Note the seed and options first. The identity comes back from the same seed, but the key does not. Keep a key for real users outside the repository (`--keystore PATH`), or phones cannot install your updates.
1026−- **Deleting `generated/` by hand** leaves the three files patched, and `--clean` refuses. Fix it with `git checkout -- TMessagesProj/src/main/java/org/telegram/messenger/ContactsController.java TMessagesProj/src/main/res/xml/auth.xml TMessagesProj/src/main/res/xml/sync_contacts.xml`, then run `--clean`. **Deleting only `rebrand.properties`** makes Gradle refuse (`[rebrand] no active rebrand ...`, see 8.1.5), because the three files still carry the rebrand's account type. `--clean` still puts them back.
1027−- **If the generator stops with an error** such as `unexpected <dir>/<name>.xml: update ICON_TARGETS`, a partial `generated/` stays behind. Run `--clean` before building. The self-test refuses to start while `generated/` exists.
1028−- **The account type count.** The generator replaces every `"org.telegram.messenger"` literal in `ContactsController.java`. If upstream adds that literal for another purpose, it would be replaced too. When the count is no longer 5, read each use, then change `JAVA_EXPECTED_LITERALS` and `selftest.py` line 270 together.
1029−- **Keep each Java hook one line, with the full name** `org.telegram.messenger.multigram.Rebrand`. No import is added, so upstream changes to the import list never conflict.
1030−- **`Rebrand.defaultTitle` reads `AppName` through Android, not through `LocaleController`**, because cloud language packs carry Telegram's name. This works because Telegram's string task keeps `AppName` as a real resource (`GENERATED_EXCLUSIONS` in `buildSrc/src/main/kotlin/org/telegram/tasks/TelegramStringsTask.kt`). If that list changes, the title may come out wrong.
1031−- **The stock title "Fork Client" appears in three hooks.** If Forkgram changes it, change all three.
1032−- **The `[rebrand] ... app name added to N TelegramStrings task(s)` message** counts tasks by name only. If `buildSrc` renames the task class or its properties, the message still appears but the in-app strings keep "Fork Client". Check `TelegramBuildAppPlugin.kt` and `TelegramStringsTask.kt` after an update.
1033−- **Forkgram's `auth.xml` and `sync_contacts.xml` say `${applicationId}`**; plain Telegram says `org.telegram.messenger`. If Forkgram switches, the generator warns and the self-test fails `seed A: no warnings`. Update `STOCK_XML_ACCOUNT_TYPE`.
1034−- **`filterLauncherIcons` keeps only `DEFAULT`.** Do not filter `LauncherIconController`'s own loops (they switch every icon on and off) or `PremiumAppIconsPreviewView` (the Premium screen).
1035−- **The self-test copies only files git tracks.** `git add` a new upstream file before you trust its result.
1036−- **Two build variants of one identity** (for example `.beta` and `.web`) share the account type, so only one of them can hold the account on a phone.
1037−
1038−### 8.2 Palette fix (5b53859682)
1039−
1040−#### 8.2.1 What and why
1041−
1042−Stock Telegram already fails the contrast minimum on many grey texts. MultiGram also gives every install a generated accent colour, which can be very light. This commit fixes both. It has four parts:
1043−
1044−1. **Static values.** 11 grey default colours in `ThemeColors.java` and 36 grey values in the five bundled themes (Blue, Dark Blue, Arctic Blue, Day, Night) move to the nearest lightness that passes 4.5 : 1. 13 neutral keys join the list of keys the accent tint skips, so an accent cannot pull them back. Monet themes are not touched.
1045−2. **The on-accent rule** (new class `PaletteFix`). For runtime accents only, marks drawn on accent fills become white or near-black `#050505`, whichever is more readable. These marks include the floating pencil, unread digits, button texts, checkmarks and the send glyph. Stock presets, server themes, other people's cloud themes and Monet look exactly as in Forkgram.
1046−3. **Outgoing bubble texts.** For runtime accents, the code that picks black or white texts for outgoing bubbles always runs.
1047−4. **Bundled theme refresh.** The app re-copies a bundled theme file after an app update, because two edited files kept their size.
1048−
1049−All values come from overlay A, which the simulator makes from plain Forkgram (8.2.5).
1050−
1051−#### 8.2.2 New files
1052−
1053−| Path | Purpose | Upstream names it relies on |
1054−|---|---|---|
1055−| `TMessagesProj/src/main/java/org/telegram/messenger/multigram/PaletteFix.java` | All the runtime logic; upstream files only call it. It holds the rule, the contrast maths, and data tables that must equal overlay A (8.2.4). A runtime accent is `accent.id > 100 && (accent.info == null \|\| accent.info.creator)`. | `Theme.getColor(int)`, `Theme.getColor(int, boolean[], boolean)`, `getFallbackKeys()`, `getDefaultColor(int)`, `getActiveTheme()`, `DEFALT_THEME_ACCENT_ID` (upstream's spelling); `ThemeAccent.id`, `info`, `parentTheme`, `accentColor`; `ThemeInfo.isMonet()` (Forkgram only), `isDark()`, `getKey()`, `firstAccentIsDefault`, `themeAccentsMap`, `chatAccentsByThemeId`; 45 `Theme.key_*` colour keys; `ApplicationLoader.applicationContext`; Android's `PackageInfo.lastUpdateTime` |
1056−| `multigram/palette-fix/overlay_A.json` | The specification: 23 defaults, 36 theme values, 13 exclusions, the on-accent pairs and the muted badge table. Colours are unsigned 32-bit ARGB. A second, identical copy is in `multigram/tools/sim/palette-fix/`. | The key names (with the `key_` prefix), the five `.attheme` file names, and the theme names "Blue", "Arctic Blue", "Day", "Dark Blue", "Night" |
1057−| `multigram/tools/palette_fix_check.py` | Checks the tree against overlay A: the values, the tables in `PaletteFix.java`, and 12 hook lines (each exactly once). With `--base REV` it also rejects any other change to `ThemeColors.java` and the 7 `.attheme` files. Prints `palette-fix: OK`. | The file paths of the hooked files; the formats `defaultColors[key_X] = ...;` and `name=<decimal>`; the exact hook texts including their comments |
1058−| `multigram/palette-fix/README.md` | Human documentation: what changes, what a runtime accent is, the hooks, and what is not hooked on purpose. | Documentation only |
1059−
1060−#### 8.2.3 Hooks
1061−
1062−##### P1. ThemeColors: 11 grey default colours
1063−
1064−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ActionBar/ThemeColors.java`
1065−
1066−**Where:** Class `ThemeColors`, method `createDefaultColors()`. Eleven `defaultColors[...] = ...;` lines, each changed in place. The values are listed in table 8.2.4.
1067−
1068−**Find this** (the method that holds the lines; it occurs once in the file, at line 20 in Forkgram 12.10.6):
1069−
1070−```java
1071− public static int[] createDefaultColors() {
1072−```
1073−
1074−**Then find these 11 lines** (each occurs once in the file):
1075−
1076−```java
1077− defaultColors[key_windowBackgroundWhiteGrayText] = 0xff808384;
1078− defaultColors[key_windowBackgroundWhiteGrayText2] = 0xff82868a;
1079− defaultColors[key_windowBackgroundWhiteGrayText4] = 0xff808080;
1080− defaultColors[key_actionBarDefaultSubtitle] = 0xff79817e; // key_windowBackgroundWhiteGrayText
1081− defaultColors[key_chats_message] = 0xff75787A;
1082− defaultColors[key_chats_date] = 0xff848688;
1083− defaultColors[key_chat_inViews] = 0xffa1aab3;
1084− defaultColors[key_chat_inTimeText] = 0xffa1aab3;
1085− defaultColors[key_chat_inFileInfoText] = 0xffa1aab3;
1086− defaultColors[key_chat_messagePanelHint] = 0xff858a84;
1087− defaultColors[key_chat_topPanelMessage] = 0xff767e7c;
1088−```
1089−
1090−**Change them to** (`-` is the Forkgram line, `+` the MultiGram line):
1091−
1092−```diff
1093−- defaultColors[key_windowBackgroundWhiteGrayText] = 0xff808384;
1094−- defaultColors[key_windowBackgroundWhiteGrayText2] = 0xff82868a;
1095−+ defaultColors[key_windowBackgroundWhiteGrayText] = 0xff757778; // MultiGram: contrast fix, was 0xff808384
1096−+ defaultColors[key_windowBackgroundWhiteGrayText2] = 0xff73777b; // MultiGram: contrast fix, was 0xff82868a
1097−- defaultColors[key_windowBackgroundWhiteGrayText4] = 0xff808080;
1098−+ defaultColors[key_windowBackgroundWhiteGrayText4] = 0xff6f6e6e; // MultiGram: contrast fix, was 0xff808080
1099−- defaultColors[key_actionBarDefaultSubtitle] = 0xff79817e; // key_windowBackgroundWhiteGrayText
1100−+ defaultColors[key_actionBarDefaultSubtitle] = 0xff717876; // key_windowBackgroundWhiteGrayText; MultiGram: contrast fix, was 0xff79817e
1101−- defaultColors[key_chats_message] = 0xff75787A;
1102−+ defaultColors[key_chats_message] = 0xff747779; // MultiGram: contrast fix, was 0xff75787A
1103−- defaultColors[key_chats_date] = 0xff848688;
1104−+ defaultColors[key_chats_date] = 0xff757778; // MultiGram: contrast fix, was 0xff848688
1105−- defaultColors[key_chat_inViews] = 0xffa1aab3;
1106−+ defaultColors[key_chat_inViews] = 0xff6f7780; // MultiGram: contrast fix, was 0xffa1aab3
1107−- defaultColors[key_chat_inTimeText] = 0xffa1aab3;
1108−+ defaultColors[key_chat_inTimeText] = 0xff6f7780; // MultiGram: contrast fix, was 0xffa1aab3
1109−- defaultColors[key_chat_inFileInfoText] = 0xffa1aab3;
1110−+ defaultColors[key_chat_inFileInfoText] = 0xff6f7780; // MultiGram: contrast fix, was 0xffa1aab3
1111−- defaultColors[key_chat_messagePanelHint] = 0xff858a84;
1112−+ defaultColors[key_chat_messagePanelHint] = 0xff737873; // MultiGram: contrast fix, was 0xff858a84
1113−- defaultColors[key_chat_topPanelMessage] = 0xff767e7c;
1114−+ defaultColors[key_chat_topPanelMessage] = 0xff717877; // MultiGram: contrast fix, was 0xff767e7c
1115−```
1116−
1117−**Why:** These are the stock default colours. The default Blue theme uses them directly, and every theme without its own value inherits them. The stock greys reach only 2.36 to 4.45 : 1 contrast against their backgrounds. The new values reach 4.50 to 4.54 : 1 with the same hue. Each changed line ends with `// MultiGram: contrast fix, was <old value>`.
1118−
1119−**If the code moved:** Match each line by its key, not by its value. If Forkgram changed the stock value of one of these keys, the replay conflicts on that line. Keep MultiGram's line to finish the replay (5.3), but not for good: re-derive the values from the new plain Forkgram with the simulator (8.2.5), use the value it gives (or drop the line if the key now passes), and update the `was 0x...` comment. If a key is renamed, see the `key_...` row of 6.2. The 12 "on-accent" keys in `overlay_A.json` (for example `key_chats_actionIcon`) must **not** be copied here: the app keeps them stock white on purpose.
1120−
1121−
1122−##### P2. assets/arctic.attheme: grey values of the "Arctic Blue" theme
1123−
1124−**File:** `TMessagesProj/src/main/assets/arctic.attheme`
1125−
1126−**Where:** Bundled theme "Arctic Blue". `key=value` lines anywhere in the file; order does not matter. Values are signed decimal colours (ARGB). The values are listed in table 8.2.4.
1127−
1128−**Then find these 4 lines** (each occurs once in the file):
1129−
1130−```properties
1131−actionBarDefaultSubtitle=-7630182
1132−chat_topPanelMessage=-8354167
1133−chat_inFileInfoText=-6182221
1134−windowBackgroundWhiteGrayText=-7629665
1135−```
1136−
1137−**Change them to** (`-` is the Forkgram line, `+` the MultiGram line):
1138−
1139−```diff
1140−-actionBarDefaultSubtitle=-7630182
1141−+actionBarDefaultSubtitle=-9341057
1142−-chat_topPanelMessage=-8354167
1143−+chat_topPanelMessage=-9275526
1144−-chat_inFileInfoText=-6182221
1145−+chat_inFileInfoText=-9472128
1146−-windowBackgroundWhiteGrayText=-7629665
1147−+windowBackgroundWhiteGrayText=-9472126
1148−```
1149−
1150−**Why:** These greys fail the 4.5 : 1 contrast minimum on the "Arctic Blue" theme in stock Forkgram. The file keeps its size (9041 bytes), so users who update need hook P12 to see the new values.
1151−
1152−**If the code moved:** Find each line by its key. If the stock value of a line changed upstream (a replay conflict), or the key disappeared, re-derive the values with the simulator (8.2.5). Write values in decimal: the checker does not read `#hex`.
1153−
1154−
1155−##### P3. assets/bluebubbles.attheme: grey values of the "Blue" theme
1156−
1157−**File:** `TMessagesProj/src/main/assets/bluebubbles.attheme`
1158−
1159−**Where:** Bundled theme "Blue". `key=value` lines anywhere in the file; order does not matter. Values are signed decimal colours (ARGB). The values are listed in table 8.2.4.
1160−
1161−**Then find these 4 lines** (each occurs once in the file):
1162−
1163−```properties
1164−actionBarDefaultSubtitle=-8156010
1165−windowBackgroundWhiteGrayText=-7565423
1166−windowBackgroundWhiteGrayText2=-7565423
1167−windowBackgroundWhiteGrayText4=-7565423
1168−```
1169−
1170−**Change them to** (`-` is the Forkgram line, `+` the MultiGram line):
1171−
1172−```diff
1173−-actionBarDefaultSubtitle=-8156010
1174−+actionBarDefaultSubtitle=-9472127
1175−-windowBackgroundWhiteGrayText=-7565423
1176−+windowBackgroundWhiteGrayText=-9144455
1177−-windowBackgroundWhiteGrayText2=-7565423
1178−+windowBackgroundWhiteGrayText2=-9144455
1179−-windowBackgroundWhiteGrayText4=-7565423
1180−+windowBackgroundWhiteGrayText4=-9671056
1181−```
1182−
1183−**Why:** These greys fail the 4.5 : 1 contrast minimum on the "Blue" theme in stock Forkgram. The file keeps its size (6073 bytes), so users who update need hook P12 to see the new values.
1184−
1185−**If the code moved:** Find each line by its key. Match by key: the old value `-7565423` is on 9 lines in this file. Only the three P3 keys that have it change; the others (for example `windowBackgroundWhiteGrayText3`) stay unchanged. If the stock value of a line changed upstream (a replay conflict), or the key disappeared, re-derive the values with the simulator (8.2.5). Write values in decimal: the checker does not read `#hex`.
1186−
1187−
1188−##### P4. assets/darkblue.attheme: grey values of the "Dark Blue" theme
1189−
1190−**File:** `TMessagesProj/src/main/assets/darkblue.attheme`
1191−
1192−**Where:** Bundled theme "Dark Blue". `key=value` lines anywhere in the file; order does not matter. Values are signed decimal colours (ARGB). The values are listed in table 8.2.4.
1193−
1194−**Then find these 12 lines** (each occurs once in the file):
1195−
1196−```properties
1197−chat_messagePanelHint=1859907583
1198−actionBarDefaultSubtitle=2111566591
1199−chat_topPanelMessage=1859974399
1200−chat_inFileInfoText=-8812137
1201−windowBackgroundWhiteGrayText=-8549479
1202−chats_message=-8549479
1203−chats_date=-9207925
1204−chat_inViews=-8812137
1205−windowBackgroundWhiteGrayText2=1878130175
1206−windowBackgroundWhiteGrayText4=-931296359
1207−chat_inTimeText=-645885536
1208−chats_attachMessage=-8548712
1209−```
1210−
1211−**Change them to** (`-` is the Forkgram line, `+` the MultiGram line):
1212−
1213−```diff
1214−-chat_messagePanelHint=1859907583
1215−+chat_messagePanelHint=-1763971073
1216−-actionBarDefaultSubtitle=2111566591
1217−+actionBarDefaultSubtitle=-1831079169
1218−-chat_topPanelMessage=1859974399
1219−+chat_topPanelMessage=-1814235905
1220−-chat_inFileInfoText=-8812137
1221−+chat_inFileInfoText=-7035469
1222−-windowBackgroundWhiteGrayText=-8549479
1223−+windowBackgroundWhiteGrayText=-7562327
1224−-chats_message=-8549479
1225−+chats_message=-7562327
1226−-chats_date=-9207925
1227−+chats_date=-7431514
1228−-chat_inViews=-8812137
1229−+chat_inViews=-7035469
1230−-windowBackgroundWhiteGrayText2=1878130175
1231−+windowBackgroundWhiteGrayText2=-2081292801
1232−-windowBackgroundWhiteGrayText4=-931296359
1233−-chat_inTimeText=-645885536
1234−+windowBackgroundWhiteGrayText4=-209876071
1235−+chat_inTimeText=-7035724
1236−-chats_attachMessage=-8548712
1237−+chats_attachMessage=-6710887
1238−```
1239−
1240−**Why:** These greys fail the 4.5 : 1 contrast minimum on the "Dark Blue" theme in stock Forkgram.
1241−
1242−**If the code moved:** Find each line by its key. Keep `chats_attachMessage` a pure grey (red = green = blue) if you re-derive it: the key is accent blue on the light themes, so it cannot join the exclusion list, and a pure grey is never re-tinted. If the stock value of a line changed upstream (a replay conflict), or the key disappeared, re-derive the values with the simulator (8.2.5). Write values in decimal: the checker does not read `#hex`.
1243−
1244−
1245−##### P5. assets/day.attheme: grey values of the "Day" theme
1246−
1247−**File:** `TMessagesProj/src/main/assets/day.attheme`
1248−
1249−**Where:** Bundled theme "Day". `key=value` lines anywhere in the file; order does not matter. Values are signed decimal colours (ARGB). The values are listed in table 8.2.4.
1250−
1251−**Then find these 6 lines** (each occurs once in the file):
1252−
1253−```properties
1254−actionBarDefaultSubtitle=-8814210
1255−chat_topPanelMessage=-9011588
1256−chat_inFileInfoText=-7565679
1257−windowBackgroundWhiteGrayText=-8354940
1258−chat_inViews=-274882397
1259−chat_inTimeText=-7105127
1260−```
1261−
1262−**Change them to** (`-` is the Forkgram line, `+` the MultiGram line):
1263−
1264−```diff
1265−-actionBarDefaultSubtitle=-8814210
1266−+actionBarDefaultSubtitle=-9340810
1267−-chat_topPanelMessage=-9011588
1268−+chat_topPanelMessage=-9340809
1269−-chat_inFileInfoText=-7565679
1270−+chat_inFileInfoText=-9671312
1271−-windowBackgroundWhiteGrayText=-8354940
1272−+windowBackgroundWhiteGrayText=-9078920
1273−-chat_inViews=-274882397
1274−+chat_inViews=-9736591
1275−-chat_inTimeText=-7105127
1276−+chat_inTimeText=-9671311
1277−```
1278−
1279−**Why:** These greys fail the 4.5 : 1 contrast minimum on the "Day" theme in stock Forkgram.
1280−
1281−**If the code moved:** Find each line by its key. If the stock value of a line changed upstream (a replay conflict), or the key disappeared, re-derive the values with the simulator (8.2.5). Write values in decimal: the checker does not read `#hex`.
1282−
1283−
1284−##### P6. assets/night.attheme: grey values of the "Night" theme
1285−
1286−**File:** `TMessagesProj/src/main/assets/night.attheme`
1287−
1288−**Where:** Bundled theme "Night". `key=value` lines anywhere in the file; order does not matter. Values are signed decimal colours (ARGB). The values are listed in table 8.2.4.
1289−
1290−**Then find these 10 lines** (each occurs once in the file):
1291−
1292−```properties
1293−chat_messagePanelHint=1694498815
1294−actionBarDefaultSubtitle=1945301746
1295−chat_topPanelMessage=1694498815
1296−chat_inFileInfoText=-9013641
1297−windowBackgroundWhiteGrayText=1862270975
1298−chats_date=-8882056
1299−chat_inViews=-8881024
1300−windowBackgroundWhiteGrayText2=1862139391
1301−windowBackgroundWhiteGrayText4=-9803158
1302−chat_inTimeText=-8552575
1303−```
1304−
1305−**Change them to** (`-` is the Forkgram line, `+` the MultiGram line):
1306−
1307−```diff
1308−-chat_messagePanelHint=1694498815
1309−+chat_messagePanelHint=1979711487
1310−-actionBarDefaultSubtitle=1945301746
1311−+actionBarDefaultSubtitle=-2131561742
1312−-chat_topPanelMessage=1694498815
1313−+chat_topPanelMessage=1962934271
1314−-chat_inFileInfoText=-9013641
1315−+chat_inFileInfoText=-7763573
1316−-windowBackgroundWhiteGrayText=1862270975
1317−+windowBackgroundWhiteGrayText=1962934271
1318−-chats_date=-8882056
1319−+chats_date=-8289920
1320−-chat_inViews=-8881024
1321−+chat_inViews=-8025458
1322−-windowBackgroundWhiteGrayText2=1862139391
1323−+windowBackgroundWhiteGrayText2=1962802687
1324−-windowBackgroundWhiteGrayText4=-9803158
1325−-chat_inTimeText=-8552575
1326−+windowBackgroundWhiteGrayText4=-9079692
1327−+chat_inTimeText=-7894388
1328−```
1329−
1330−**Why:** These greys fail the 4.5 : 1 contrast minimum on the "Night" theme in stock Forkgram.
1331−
1332−**If the code moved:** Find each line by its key. Match by key: the old value `1694498815` is on 8 lines in this file. Only `chat_messagePanelHint` and `chat_topPanelMessage` change; the other six (for example `chat_messagePanelIcons`) stay unchanged. If the stock value of a line changed upstream (a replay conflict), or the key disappeared, re-derive the values with the simulator (8.2.5). Write values in decimal: the checker does not read `#hex`.
1333−
1334−
1335−##### P7. Per-chat themes: apply the on-accent rule
1336−
1337−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ActionBar/EmojiThemes.java`
1338−
1339−**Where:** Class `EmojiThemes`, method `createColors(int currentAccount, int index)`, inside `if (accent != null) {`, after the gift-theme block and before `} else {`. In Forkgram 12.10.6 the text below starts at line 429.
1340−
1341−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
1342−
1343−```java
1344− public SparseIntArray createColors(int currentAccount, int index) {
1345−```
1346−
1347−The context lines of the change below occur twice in the file. Use the copy after this line (it begins at line 472 in Forkgram 12.10.6, inside `createColors`), not the one in `getPreviewColors` (line 397).
1348−
1349−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
1350−
1351−```diff
1352− if (isGiftTheme() && accent.parentTheme != null && accent.parentTheme.isLight()) {
1353− accent.resetAccentColorsForMyMessagesGiftThemeLight(currentColors);
1354− }
1355−+ org.telegram.messenger.multigram.PaletteFix.applyToColorMap(themeInfo, accent, currentColors); // MultiGram: on-accent contrast rule
1356− } else {
1357− currentColors = currentColorsNoAccent;
1358− }
1359−```
1360−
1361−**Why:** This builds the colour map of a per-chat theme (emoji or gift theme). With the hook, marks drawn on accent fills in a themed chat stay readable while the user's own accent is a runtime accent. With a stock accent the chat looks as in Forkgram.
1362−
1363−**If the code moved:** Anchor on the method `createColors`, not on the block: the same `if (accent != null) { ... resetAccentColorsForMyMessagesGiftThemeLight ... }` block also exists in `getPreviewColors`, which is deliberately **not** hooked (it only draws a small preview). The call goes after `fillAccentColors` and the gift-theme fix, and before the map is completed with fallbacks and returned.
1364−
1365−
1366−##### P8. Out-bubble text: always recompute for runtime accents
1367−
1368−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ActionBar/Theme.java`
1369−
1370−**Where:** Class `Theme.ThemeAccent`, method `fillAccentColors(SparseIntArray, SparseIntArray)`: the condition of the block that picks black or white texts for outgoing bubbles. In Forkgram 12.10.6 the text below starts at line 679.
1371−
1372−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
1373−
1374−```java
1375− if (!isMyMessagesGradientColorsNear) {
1376−```
1377−
1378−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
1379−
1380−```diff
1381− }
1382− }
1383− }
1384−- if (!isMyMessagesGradientColorsNear) {
1385−+ if (!isMyMessagesGradientColorsNear || org.telegram.messenger.multigram.PaletteFix.isRuntimeAccent(this)) { // MultiGram: runtime accents (generated/custom) always get readable black/white out-bubble texts
1386− if (myMessagesGradientAccentColor1 != 0) {
1387− int textColor;
1388− int subTextColor;
1389−```
1390−
1391−**Why:** Upstream picks readable black or white outgoing-bubble texts only when the custom bubble colour is not close to the theme's own bubble. A generated light bubble can be close to Blue's bubble and then keeps stock texts that fail contrast. For runtime accents the block now always runs.
1392−
1393−**If the code moved:** Wherever `fillAccentColors` decides whether to recompute out-bubble text colours, make runtime accents always take the recompute path. Keep the line exactly as it is: `palette_fix_check.py` matches it, and the simulator (`tgsrc.py`, `style_table.py`) looks for this exact guard to know the palette fix is in the tree.
1394−
1395−
1396−##### P9. Exported accent themes carry the rule
1397−
1398−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ActionBar/Theme.java`
1399−
1400−**Where:** Class `Theme.ThemeAccent`, method `saveToFile()`, right after `fillAccentColors(...)`. In Forkgram 12.10.6 the text below starts at line 1041.
1401−
1402−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
1403−
1404−```java
1405− SparseIntArray currentColorsNoAccent = getThemeFileValues(null, parentTheme.assetName, null);
1406− SparseIntArray currentColors = currentColorsNoAccent.clone();
1407− fillAccentColors(currentColorsNoAccent, currentColors);
1408−```
1409−
1410−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
1411−
1412−```diff
1413− SparseIntArray currentColorsNoAccent = getThemeFileValues(null, parentTheme.assetName, null);
1414− SparseIntArray currentColors = currentColorsNoAccent.clone();
1415− fillAccentColors(currentColorsNoAccent, currentColors);
1416−+ org.telegram.messenger.multigram.PaletteFix.applyToColorMap(parentTheme, this, currentColors); // MultiGram: on-accent contrast rule
1417−
1418− String wallpaperLink = null;
1419−
1420−```
1421−
1422−**Why:** An accent saved as an `.attheme` file carries the same readable marks the user sees. Stock accents export unchanged.
1423−
1424−**If the code moved:** Wherever an accent's colour map is built for export, apply the rule after `fillAccentColors` and before the map is written.
1425−
1426−
1427−##### P10. Keep 13 neutral keys out of the accent tint
1428−
1429−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ActionBar/Theme.java`
1430−
1431−**Where:** Class `Theme`, static initializer: after the last `themeAccentExclusionKeys.add(...)` and before `themes = new ArrayList<>();`. In Forkgram 12.10.6 the text below starts at line 3886.
1432−
1433−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
1434−
1435−```java
1436− themeAccentExclusionKeys.add(key_stories_circle_closeFriends2);
1437−```
1438−
1439−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
1440−
1441−```diff
1442− themeAccentExclusionKeys.add(key_stories_circle_dialog2);
1443− themeAccentExclusionKeys.add(key_stories_circle_closeFriends1);
1444− themeAccentExclusionKeys.add(key_stories_circle_closeFriends2);
1445−+ org.telegram.messenger.multigram.PaletteFix.addAccentExclusions(themeAccentExclusionKeys); // MultiGram: neutral greys stay readable under any accent
1446−
1447−
1448− themes = new ArrayList<>();
1449−```
1450−
1451−**Why:** `fillAccentColors` shifts the hue of most colours towards the accent. Keys in `themeAccentExclusionKeys` are skipped. The hook adds 13 neutral keys (the 11 greys, `windowBackgroundGray` and `chat_messagePanelIcons`), so an accent cannot tint them back below the contrast minimum.
1452−
1453−**If the code moved:** Call it once, after upstream has filled `themeAccentExclusionKeys` and before any theme is loaded (still in the static initializer, after the `key_*` fields exist). If upstream turns the set into another type, change the parameter type of `PaletteFix.addAccentExclusions`.
1454−
1455−
1456−##### P11. The global hook: apply the rule to the app's colours
1457−
1458−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ActionBar/Theme.java`
1459−
1460−**Where:** Class `Theme`, method `refreshThemeColors(boolean bg, boolean messages)`: after the calculated table and article colours, before `reloadWallpaper` and `applyCommonTheme`. In Forkgram 12.10.6 the text below starts at line 6107.
1461−
1462−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
1463−
1464−```java
1465− applyCalculatedArticleCodeColors(currentColorsNoAccent, currentColors, currentTheme.isDark());
1466−```
1467−
1468−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
1469−
1470−```diff
1471− }
1472− applyCalculatedTableColors(currentColorsNoAccent, currentColors, currentTheme.isDark());
1473− applyCalculatedArticleCodeColors(currentColorsNoAccent, currentColors, currentTheme.isDark());
1474−+ org.telegram.messenger.multigram.PaletteFix.applyToCurrentColors(currentTheme, accent, currentColors); // MultiGram: on-accent contrast rule
1475− if (!messages) {
1476− boolean async = !(LaunchActivity.getLastFragment() instanceof ChatActivity);
1477− reloadWallpaper(async);
1478−```
1479−
1480−**Why:** This is the main hook. It decides whether the current theme and accent get the rule, and remembers the answer for hooks P14 to P18. When the rule is on, it sets the marks drawn on accent fills (the floating pencil, unread digits, checkmarks, button texts) to white or near-black `#050505`, whichever reads better, and gives the muted unread badge a matching fill.
1481−
1482−**If the code moved:** It must run every time the global colour map is rebuilt: after the accent tint and all calculated colours, and before `applyCommonTheme`, `applyDialogsTheme` and `applyChatTheme` copy colours into paints. The local variable `accent` must be in scope; pass `null` if upstream removes it.
1483−
1484−
1485−##### P12. Re-copy bundled themes after an app update
1486−
1487−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ActionBar/Theme.java`
1488−
1489−**Where:** Class `Theme`, method `getAssetFile(String assetName)`: the condition that decides whether to copy the asset again. In Forkgram 12.10.6 the text below starts at line 7251.
1490−
1491−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
1492−
1493−```java
1494− if (!file.exists() || size != 0 && file.length() != size) {
1495−```
1496−
1497−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
1498−
1499−```diff
1500− size = 0;
1501− FileLog.e(e);
1502− }
1503−- if (!file.exists() || size != 0 && file.length() != size) {
1504−+ if (!file.exists() || size != 0 && file.length() != size || org.telegram.messenger.multigram.PaletteFix.isStaleAssetCopy(file)) { // MultiGram: also re-copy after an app update (same-size .attheme edits)
1505− try (InputStream in = ApplicationLoader.applicationContext.getAssets().open(assetName)) {
1506− AndroidUtilities.copyFile(in, file);
1507− } catch (Exception e) {
1508−```
1509−
1510−**Why:** The app reads bundled themes from a copy in its files folder, and upstream refreshes that copy only when the asset's size changes. `arctic.attheme` and `bluebubbles.attheme` keep their size, so without this hook users who update would keep the old greys. `isStaleAssetCopy` returns true at most once per file per app start, when the copy is older than the app update.
1511−
1512−**If the code moved:** Wherever a bundled asset is copied to storage and reused, also refresh it after an app update. If upstream starts reading assets directly (no copy), drop this hook.
1513−
1514−
1515−##### P13. Channel colour screen
1516−
1517−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ChannelColorActivity.java`
1518−
1519−**Where:** Class `ChannelColorActivity`, method `updateThemeColors()`, inside `if (accent != null) {`, right after `fillAccentColors`. In Forkgram 12.10.6 the text below starts at line 2582.
1520−
1521−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
1522−
1523−```java
1524− accent.fillAccentColors(themeColors, currentColors);
1525−```
1526−
1527−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
1528−
1529−```diff
1530− Theme.ThemeAccent accent = themeInfo.getAccent(false);
1531− if (accent != null) {
1532− accent.fillAccentColors(themeColors, currentColors);
1533−+ org.telegram.messenger.multigram.PaletteFix.applyToColorMap(themeInfo, accent, currentColors); // MultiGram: on-accent contrast rule
1534− }
1535− }
1536− dividerPaint.setColor(Theme.getColor(Theme.key_divider, resourceProvider));
1537−```
1538−
1539−**Why:** This screen builds its own colour map for its previews. With the hook, the previews match what the app shows.
1540−
1541−**If the code moved:** Put the call right after `fillAccentColors` wherever a screen builds its own colour map to draw UI. After an update run `git grep -n 'fillAccentColors(' -- TMessagesProj/src/main/java ':!TMessagesProj/src/main/java/org/telegram/messenger/multigram'`. Today it prints 11 lines: the method itself and 10 calls. (Without the last pathspec it prints a 12th line on a MultiGram tree: a comment in `PaletteFix.java`.) Six calls are hooked (P7, P9, P11, P13, P16, P17). Four are left alone on purpose: `EmojiThemes.getPreviewColors` (twice), `ThemePreviewDrawable` and `Stories/recorder/PreviewView`. Decide for any new call.
1542−
1543−
1544−##### P14. Send button: loading arc colour
1545−
1546−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Components/ChatActivityEnterView.java`
1547−
1548−**Where:** Inner class `ChatActivityEnterView.SendButton`, method `onDraw(Canvas)`, inside `if (loadingShown > 0) {`. In Forkgram 12.10.6 the text below starts at line 15512.
1549−
1550−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
1551−
1552−```java
1553− loadingPaint.setColor(0xFFFFFFFF);
1554−```
1555−
1556−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
1557−
1558−```diff
1559− }
1560− canvas.clipPath(path);
1561− if (loadingShown > 0) {
1562−- loadingPaint.setColor(0xFFFFFFFF);
1563−+ loadingPaint.setColor(multigramGlyphColor != 0 ? multigramGlyphColor : 0xFFFFFFFF); // MultiGram: was 0xFFFFFFFF
1564− loadingPaint.setAlpha((int) (0xFF * loadingShown));
1565− final float R = dp(8.66f);
1566− AndroidUtilities.rectTmp.set(right - w / 2.0f - R, cy - R, right - w / 2.0f + R, cy + R);
1567−```
1568−
1569−**Why:** The progress arc drawn on the send button's fill uses the same readable colour as the icon. `multigramGlyphColor` is the field added in P15; 0 means "keep stock white".
1570−
1571−**If the code moved:** Any mark that `SendButton` draws in fixed white on its accent fill should use `multigramGlyphColor` when it is not 0. `palette_fix_check.py` does not check this line; check it by hand.
1572−
1573−
1574−##### P15. Send button: glyph colour on the fill
1575−
1576−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Components/ChatActivityEnterView.java`
1577−
1578−**Where:** Inner class `ChatActivityEnterView.SendButton`: a new field after `private int drawableColor;`, and method `updateColors()`. In Forkgram 12.10.6 the text below starts at line 15674.
1579−
1580−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
1581−
1582−```java
1583− private int drawableColor;
1584−
1585− public void updateColors() {
1586−```
1587−
1588−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
1589−
1590−```diff
1591− }
1592−
1593− private int drawableColor;
1594−+ private int multigramGlyphColor; // MultiGram: glyph colour on the fill for generated styles, 0 = stock
1595−
1596− public void updateColors() {
1597−- int color = isNewDesignSendButton ? Color.WHITE : Theme.getColor(Theme.key_chat_messagePanelSend, resourcesProvider);
1598−- if (color != drawableColor) {
1599−+ int glyph = org.telegram.messenger.multigram.PaletteFix.getGlyphColorOnFill(isNewDesignSendButton ? Theme.getColor(Theme.key_chat_messagePanelSend, resourcesProvider) : shouldDrawBackground() ? getFillColor() : 0); // MultiGram: readable glyph on generated accent fills
1600−+ int color = isNewDesignSendButton ? (glyph != 0 ? glyph : Color.WHITE) : Theme.getColor(Theme.key_chat_messagePanelSend, resourcesProvider); // MultiGram: was Color.WHITE for the new design
1601−+ if (color != drawableColor || glyph != multigramGlyphColor) { // MultiGram: was color != drawableColor
1602− drawableColor = color;
1603−+ multigramGlyphColor = glyph; // MultiGram
1604− drawable.setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN));
1605− int c = Theme.getColor(Theme.key_glass_defaultIcon, resourcesProvider);
1606− inactiveDrawable.setColorFilter(new PorterDuffColorFilter(Color.argb(0xb4, Color.red(c), Color.green(c), Color.blue(c)), PorterDuff.Mode.SRC_IN));
1607−- drawableInverse.setColorFilter(new PorterDuffColorFilter(Theme.getColor(Theme.key_chat_messagePanelVoicePressed, resourcesProvider), PorterDuff.Mode.SRC_IN));
1608−+ drawableInverse.setColorFilter(new PorterDuffColorFilter(glyph != 0 ? glyph : Theme.getColor(Theme.key_chat_messagePanelVoicePressed, resourcesProvider), PorterDuff.Mode.SRC_IN)); // MultiGram: was the voicePressed colour only
1609−+ count.setTextColor(glyph != 0 && !isNewDesignSendButton ? glyph : 0xFFFFFFFF); // MultiGram: counter digits on the fill (old design)
1610−+ priceText.setTextColor(glyph != 0 ? glyph : 0xFFFFFFFF); // MultiGram: star price on the fill
1611− }
1612− if (isNewDesignSendButton) {
1613− backgroundPaint.setColor(Theme.getColor(Theme.key_chat_messagePanelSend, resourcesProvider));
1614−```
1615−
1616−**Why:** Upstream draws the send button's glyph in fixed white on the accent fill. While the rule is on, `getGlyphColorOnFill` returns white or `#050505` for that fill (0 otherwise, which keeps stock). The glyph, the inverse glyph, the old-design counter digits and the star price follow it. The extra `|| glyph != multigramGlyphColor` makes a style change repaint even when `color` did not change.
1617−
1618−**If the code moved:** In the send button's colour update, compute the glyph colour from the colour of the fill it sits on, and use it for every mark drawn on that fill. It relies on these `SendButton` members: `isNewDesignSendButton`, `shouldDrawBackground()`, `getFillColor()`, `drawable`, `drawableInverse`, `count`, `priceText`, `resourcesProvider`. The checker looks only at the `int glyph = ...` and `drawableInverse...` lines; check the rest by hand.
1619−
1620−
1621−##### P16. Message-in-story preview
1622−
1623−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Components/Paint/Views/MessageEntityView.java`
1624−
1625−**Where:** Class `MessageEntityView`, method `setupTheme(StoryEntry)`, inside `if (accent != null) {`, right after `fillAccentColors`. In Forkgram 12.10.6 the text below starts at line 1399.
1626−
1627−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
1628−
1629−```java
1630− accent.fillAccentColors(themeColors, currentColors);
1631−```
1632−
1633−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
1634−
1635−```diff
1636− Theme.ThemeAccent accent = themeInfo.getAccent(false);
1637− if (accent != null) {
1638− accent.fillAccentColors(themeColors, currentColors);
1639−+ org.telegram.messenger.multigram.PaletteFix.applyToColorMap(themeInfo, accent, currentColors); // MultiGram: on-accent contrast rule
1640− }
1641− }
1642−
1643−```
1644−
1645−**Why:** The message shown inside a story builds its own colour map. With the hook, its marks follow the rule exactly when the app's colours do.
1646−
1647−**If the code moved:** As for P13.
1648−
1649−
1650−##### P17. Profile colour screen
1651−
1652−**File:** `TMessagesProj/src/main/java/org/telegram/ui/PeerColorActivity.java`
1653−
1654−**Where:** Class `PeerColorActivity`, method `updateThemeColors()`, inside `if (accent != null) {`, right after `fillAccentColors`. In Forkgram 12.10.6 the text below starts at line 1496.
1655−
1656−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
1657−
1658−```java
1659− accent.fillAccentColors(themeColors, currentColors);
1660−```
1661−
1662−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
1663−
1664−```diff
1665− Theme.ThemeAccent accent = themeInfo.getAccent(false);
1666− if (accent != null) {
1667− accent.fillAccentColors(themeColors, currentColors);
1668−+ org.telegram.messenger.multigram.PaletteFix.applyToColorMap(themeInfo, accent, currentColors); // MultiGram: on-accent contrast rule
1669− }
1670−
1671− if (namePage != null && namePage.messagesCellPreview != null) {
1672−```
1673−
1674−**Why:** The name and profile colour screen builds its own colour map for its message previews. With the hook, they match the app.
1675−
1676−**If the code moved:** As for P13.
1677−
1678−
1679−##### P18. Stories and calls: marks on overridden fills
1680−
1681−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Stories/DarkThemeResourceProvider.java`
1682−
1683−**Where:** Class `DarkThemeResourceProvider`, method `getColor(int key)`: the last `return`, for keys the provider does not override. In Forkgram 12.10.6 the text below starts at line 205.
1684−
1685−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
1686−
1687−```java
1688− return Theme.getColor(key);
1689−```
1690−
1691−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
1692−
1693−```diff
1694− if (!debugUnknownKeys.contains(key)) {
1695− debugUnknownKeys.add(key);
1696− }
1697−- return Theme.getColor(key);
1698−+ return org.telegram.messenger.multigram.PaletteFix.getOverrideFallbackColor(sparseIntArray, key); // MultiGram: was Theme.getColor(key); marks on fills overridden here stay readable
1699− }
1700−
1701− Drawable msgOutMedia;
1702−```
1703−
1704−**Why:** This provider (used by stories and group calls) replaces some fills, such as `dialogRoundCheckBox`, but not the marks drawn on them. While the rule is on, the app's mark colour was chosen for the app's fill. For a mark whose fill this provider replaces, PaletteFix picks white or `#050505` again for the replaced fill. Every other key still returns `Theme.getColor(key)`.
1705−
1706−**If the code moved:** In resource providers that replace some colours and fall back to the global theme, send the fallback through `getOverrideFallbackColor`. `sparseIntArray` is the provider's map today; rename it in the call if upstream renames the field.
1707−
1708−
1709−#### 8.2.4 Data
1710−
1711−The values below are taken from the repository. The contrast columns come from the simulator's report (`overlay_A_rows.json`), rebuilt on Forkgram 12.10.6. Each is measured against the key's background in that theme.
1712−
1713−**`ThemeColors.java` defaults** (hook P1; used by the Blue theme and inherited by every theme without its own value):
1714−
1715−| Key | Forkgram 12.10.6 | MultiGram | Contrast before → after |
1716−|---|---|---|---|
1717−| `windowBackgroundWhiteGrayText` | `0xff808384` | `0xff757778` | 3.82 → 4.50 |
1718−| `windowBackgroundWhiteGrayText2` | `0xff82868a` | `0xff73777b` | 3.67 → 4.51 |
1719−| `windowBackgroundWhiteGrayText4` | `0xff808080` | `0xff6f6e6e` | 3.50 → 4.51 |
1720−| `actionBarDefaultSubtitle` | `0xff79817e` | `0xff717876` | 4.00 → 4.52 |
1721−| `chats_message` | `0xff75787A` | `0xff747779` | 4.45 → 4.51 |
1722−| `chats_date` | `0xff848688` | `0xff757778` | 3.65 → 4.50 |
1723−| `chat_inViews` | `0xffa1aab3` | `0xff6f7780` | 2.36 → 4.54 |
1724−| `chat_inTimeText` | `0xffa1aab3` | `0xff6f7780` | 2.36 → 4.54 |
1725−| `chat_inFileInfoText` | `0xffa1aab3` | `0xff6f7780` | 2.36 → 4.54 |
1726−| `chat_messagePanelHint` | `0xff858a84` | `0xff737873` | 3.52 → 4.50 |
1727−| `chat_topPanelMessage` | `0xff767e7c` | `0xff717877` | 4.16 → 4.51 |
1728−
1729−**Bundled themes** (hooks P2 to P6). The files store signed decimal numbers; the "As colours" column shows the same values as `#AARRGGBB`.
1730−
1731−**`arctic.attheme`** (theme "Arctic Blue")
1732−
1733−| Key | Forkgram 12.10.6 | MultiGram | As colours | Contrast before → after |
1734−|---|---|---|---|---|
1735−| `actionBarDefaultSubtitle` | `-7630182` | `-9341057` | #FF8B929A → #FF71777F | 3.15 → 4.52 |
1736−| `chat_topPanelMessage` | `-8354167` | `-9275526` | #FF808689 → #FF72777A | 3.69 → 4.53 |
1737−| `chat_inFileInfoText` | `-6182221` | `-9472128` | #FFA1AAB3 → #FF6F7780 | 2.36 → 4.54 |
1738−| `windowBackgroundWhiteGrayText` | `-7629665` | `-9472126` | #FF8B949F → #FF6F7782 | 3.07 → 4.53 |
1739−
1740−**`bluebubbles.attheme`** (theme "Blue")
1741−
1742−| Key | Forkgram 12.10.6 | MultiGram | As colours | Contrast before → after |
1743−|---|---|---|---|---|
1744−| `actionBarDefaultSubtitle` | `-8156010` | `-9472127` | #FF838C96 → #FF6F7781 | 3.41 → 4.53 |
1745−| `windowBackgroundWhiteGrayText` | `-7565423` | `-9144455` | #FF8C8F91 → #FF747779 | 3.25 → 4.51 |
1746−| `windowBackgroundWhiteGrayText2` | `-7565423` | `-9144455` | #FF8C8F91 → #FF747779 | 3.25 → 4.51 |
1747−| `windowBackgroundWhiteGrayText4` | `-7565423` | `-9671056` | #FF8C8F91 → #FF6C6E70 | 2.88 → 4.54 |
1748−
1749−**`darkblue.attheme`** (theme "Dark Blue")
1750−
1751−| Key | Forkgram 12.10.6 | MultiGram | As colours | Contrast before → after |
1752−|---|---|---|---|---|
1753−| `chat_messagePanelHint` | `1859907583` | `-1763971073` | #6EDBEFFF → #96DBEFFF | 3.49 → 4.52 |
1754−| `actionBarDefaultSubtitle` | `2111566591` | `-1831079169` | #7DDBF2FF → #92DBF2FF | 4.12 → 4.51 |
1755−| `chat_topPanelMessage` | `1859974399` | `-1814235905` | #6EDCF4FF → #93DCF4FF | 3.57 → 4.54 |
1756−| `chat_inFileInfoText` | `-8812137` | `-7035469` | #FF798997 → #FF94A5B3 | 3.83 → 4.54 |
1757−| `windowBackgroundWhiteGrayText` | `-8549479` | `-7562327` | #FF7D8B99 → #FF8C9BA9 | 4.33 → 4.53 |
1758−| `chats_message` | `-8549479` | `-7562327` | #FF7D8B99 → #FF8C9BA9 | 4.33 → 4.53 |
1759−| `chats_date` | `-9207925` | `-7431514` | #FF737F8B → #FF8E9AA6 | 3.70 → 4.50 |
1760−| `chat_inViews` | `-8812137` | `-7035469` | #FF798997 → #FF94A5B3 | 3.83 → 4.54 |
1761−| `windowBackgroundWhiteGrayText2` | `1878130175` | `-2081292801` | #6FF1FDFF → #83F1FDFF | 3.95 → 4.51 |
1762−| `windowBackgroundWhiteGrayText4` | `-931296359` | `-209876071` | #C87D8B99 → #F37D8B99 | 3.48 → 4.52 |
1763−| `chat_inTimeText` | `-645885536` | `-7035724` | #D98091A0 → #FF94A4B4 | 3.49 → 4.50 |
1764−| `chats_attachMessage` | `-8548712` | `-6710887` | #FF7D8E98 → #FF999999 | 4.46 → 4.53 |
1765−
1766−**`day.attheme`** (theme "Day")
1767−
1768−| Key | Forkgram 12.10.6 | MultiGram | As colours | Contrast before → after |
1769−|---|---|---|---|---|
1770−| `actionBarDefaultSubtitle` | `-8814210` | `-9340810` | #FF79817E → #FF717876 | 4.00 → 4.52 |
1771−| `chat_topPanelMessage` | `-9011588` | `-9340809` | #FF767E7C → #FF717877 | 4.16 → 4.51 |
1772−| `chat_inFileInfoText` | `-7565679` | `-9671312` | #FF8C8E91 → #FF6C6D70 | 2.88 → 4.54 |
1773−| `windowBackgroundWhiteGrayText` | `-8354940` | `-9078920` | #FF808384 → #FF757778 | 3.82 → 4.50 |
1774−| `chat_inViews` | `-274882397` | `-9736591` | #EF9DA0A3 → #FF6B6E71 | 2.17 → 4.50 |
1775−| `chat_inTimeText` | `-7105127` | `-9671311` | #FF939599 → #FF6C6D71 | 2.63 → 4.53 |
1776−
1777−**`night.attheme`** (theme "Night")
1778−
1779−| Key | Forkgram 12.10.6 | MultiGram | As colours | Contrast before → after |
1780−|---|---|---|---|---|
1781−| `chat_messagePanelHint` | `1694498815` | `1979711487` | #64FFFFFF → #75FFFFFF | 3.67 → 4.52 |
1782−| `actionBarDefaultSubtitle` | `1945301746` | `-2131561742` | #73F2F2F2 → #80F2F2F2 | 3.98 → 4.50 |
1783−| `chat_topPanelMessage` | `1694498815` | `1962934271` | #64FFFFFF → #74FFFFFF | 3.72 → 4.53 |
1784−| `chat_inFileInfoText` | `-9013641` | `-7763573` | #FF767677 → #FF89898B | 3.56 → 4.50 |
1785−| `windowBackgroundWhiteGrayText` | `1862270975` | `1962934271` | #6EFFFFFF → #74FFFFFF | 4.25 → 4.56 |
1786−| `chats_date` | `-8882056` | `-8289920` | #FF787878 → #FF818180 | 4.02 → 4.51 |
1787−| `chat_inViews` | `-8881024` | `-8025458` | #FF787C80 → #FF858A8E | 3.84 → 4.51 |
1788−| `windowBackgroundWhiteGrayText2` | `1862139391` | `1962802687` | #6EFDFDFF → #74FDFDFF | 4.20 → 4.50 |
1789−| `windowBackgroundWhiteGrayText4` | `-9803158` | `-9079692` | #FF6A6A6A → #FF757474 | 3.88 → 4.51 |
1790−| `chat_inTimeText` | `-8552575` | `-7894388` | #FF7D7F81 → #FF878A8C | 4.02 → 4.52 |
1791−
1792−**Keys the accent tint skips** (hook P10, `PaletteFix.addAccentExclusions`). The order does not matter.
1793−
1794−| # | Key |
1795−|---|---|
1796−| 1 | `key_windowBackgroundGray` |
1797−| 2 | `key_windowBackgroundWhiteGrayText` |
1798−| 3 | `key_windowBackgroundWhiteGrayText2` |
1799−| 4 | `key_chats_message` |
1800−| 5 | `key_chats_date` |
1801−| 6 | `key_actionBarDefaultSubtitle` |
1802−| 7 | `key_chat_inTimeText` |
1803−| 8 | `key_chat_inFileInfoText` |
1804−| 9 | `key_chat_messagePanelHint` |
1805−| 10 | `key_windowBackgroundWhiteGrayText4` |
1806−| 11 | `key_chat_topPanelMessage` |
1807−| 12 | `key_chat_inViews` |
1808−| 13 | `key_chat_messagePanelIcons` |
1809−
1810−**Marks and the fills they sit on** (`PaletteFix.Keys.ON_ACCENT`). While the rule is on, each mark becomes white or `#FF050505` for its fill. The order must match overlay A, with the extra voice pair last.
1811−
1812−| # | Mark (content key) | Fill it sits on |
1813−|---|---|---|
1814−| 1 | `key_chats_actionIcon` | `key_chats_actionBackground` |
1815−| 2 | `key_chats_unreadCounterText` | `key_chats_unreadCounter` |
1816−| 3 | `key_featuredStickers_buttonText` | `key_featuredStickers_addButton` |
1817−| 4 | `key_checkboxSquareCheck` | `key_checkboxSquareBackground` |
1818−| 5 | `key_dialogRoundCheckBoxCheck` | `key_dialogRoundCheckBox` |
1819−| 6 | `key_chat_goDownButtonCounter` | `key_chat_goDownButtonCounterBackground` |
1820−| 7 | `key_dialogCheckboxSquareCheck` | `key_dialogCheckboxSquareBackground` |
1821−| 8 | `key_chat_attachCheckBoxCheck` | `key_chat_attachCheckBoxBackground` |
1822−| 9 | `key_chat_inMediaIcon` | `key_chat_inLoader` |
1823−| 10 | `key_chat_inContactIcon` | `key_chat_inContactBackground` |
1824−| 11 | `key_picker_badgeText` | `key_picker_badge` |
1825−| 12 | `key_dialogFloatingIcon` | `key_dialogFloatingButton` |
1826−| 13 | `key_chat_messagePanelVoicePressed` | `key_chat_messagePanelVoiceBackground` (extra pair, only in `PaletteFix.java`) |
1827−
1828−**Muted unread badge** (`PaletteFix.MUTED_FILL`). While the rule is on, the badge (`key_chats_unreadCounterMuted`) gets a neutral fill that suits the digit colour. Four keys normally borrow the badge colour: `key_avatar_backgroundArchived`, `key_chats_archivePinBackground`, `key_chats_archivePullDownBackground` and `key_checkboxDisabled` (`MUTED_PINS`). Before it changes the badge, the rule writes their current colour explicitly, so they keep their look.
1829−
1830−| Theme | Badge fill with white digits | Badge fill with near-black digits |
1831−|---|---|---|
1832−| Blue | `#FF73777B` | `#FFBEC3C7` |
1833−| Arctic Blue | `#FF73777B` | `#FFBEC3C7` |
1834−| Day | `#FF73777B` | `#FFBEC3C7` |
1835−| Dark Blue | `#FF3E5263` | `#FF63798B` |
1836−| Night | `#FF454545` | `#FF777676` |
1837−
1838−#### 8.2.5 Regenerate and verify
1839−
1840−**1. Check the tree** (a few seconds). From the root of a checkout:
1841−
1842−```sh
1843−python3 multigram/tools/palette_fix_check.py
1844−python3 multigram/tools/palette_fix_check.py --base origin/forkgram
1845−```
1846−
1847−Success prints one line, `palette-fix: OK`, and exit status 0. A failure prints one line per problem, then `palette-fix: FAILED, N problem(s)`, and exit status 1. Two real problem lines, from a test copy with one value changed and one hook line deleted:
1848−
1849−```text
1850−palette-fix: ThemeColors key_chat_topPanelMessage = ['0xff717878'], expected 0xff717877
1851−palette-fix: hook 'PaletteFix\\.applyToColorMap\\(themeInfo, accent, currentColors\\); // MultiGram:' found 0 times in TMessagesProj/src/main/java/org/telegram/ui/PeerColorActivity.java
1852−```
1853−
1854−`--base` must name the snapshot the tree is built on. On the MultiGram tree, `--base dc780e81e` (plain Telegram) reports 1450 false problems, because Forkgram's Monet theme files then count as changes.
1855−
1856−This check compares the tree with overlay A. It cannot tell whether overlay A still suits the new Forkgram. The simulator does that.
1857−
1858−**2. Re-derive the values from the new Forkgram** (about 3 seconds). The simulator treats the tree it reads as stock, so give it the plain new snapshot, never the MultiGram tree:
1859−
1860−```sh
1861−rm -rf /tmp/fk-new /tmp/overlay-new
1862−mkdir -p /tmp/fk-new /tmp/overlay-new
1863−git archive origin/forkgram TMessagesProj/src/main/java/org/telegram/ui/ActionBar TMessagesProj/src/main/assets | tar -x -C /tmp/fk-new
1864−cd multigram/tools/sim
1865−TG_REPO=/tmp/fk-new CANVAS_OUT=/tmp/overlay-new python3 canvas.py --build > /tmp/overlay-new/build.log
1866−grep '^VARIANT A' /tmp/overlay-new/build.log
1867−cmp /tmp/overlay-new/overlay_A.json ../../palette-fix/overlay_A.json && cmp /tmp/overlay-new/overlay_A.json palette-fix/overlay_A.json && echo "overlay A unchanged"
1868−cd ../../..
1869−```
1870−
1871−- The first line clears what an earlier update left there. `tar` only adds files, so files Forkgram has since deleted would stay and be modelled.
1872−- The simulator needs only `ui/ActionBar` and the assets, so `git archive` of those two folders is enough.
1873−- Always set `CANVAS_OUT`. Without it, `--build` overwrites the committed `multigram/tools/sim/palette-fix/overlay_A.json` and `overlay_B.json`.
1874−- On Forkgram 12.10.6 it prints `VARIANT A (1.8 s): 23 defaults, 36 attheme lines, 13 exclusions`, and both `cmp` commands find no difference.
1875−
1876−If `overlay A unchanged` appears, there is nothing to do. If not, see what changed:
1877−
1878−```sh
1879−diff <(python3 -m json.tool --sort-keys multigram/palette-fix/overlay_A.json) <(python3 -m json.tool --sort-keys /tmp/overlay-new/overlay_A.json)
1880−```
1881−
1882−`/tmp/overlay-new/build.log` and `overlay_A_rows.json` explain each value: theme, key, old and new colour, contrast before and after, and the source file and line.
1883−
1884−To adopt a new overlay:
1885−
1886−1. Copy `/tmp/overlay-new/overlay_A.json` over **both** `multigram/palette-fix/overlay_A.json` and `multigram/tools/sim/palette-fix/overlay_A.json`.
1887−2. Write the new values into `ThemeColors.java` and the `.attheme` files, and the new tables into `PaletteFix.java`. `palette_fix_check.py` lists every line that differs, with the expected value. Remember the `// MultiGram: contrast fix, was <old value>` comments in `ThemeColors.java`.
1888−3. Run `python3 multigram/tools/palette_fix_check.py --base origin/forkgram` until it says OK.
1889−4. Regenerate the style table (8.3.5), because it is checked against overlay A.
1890−5. Fold the changes in with one fixup per commit (6.3): `multigram/palette-fix/overlay_A.json`, `ThemeColors.java`, the `.attheme` files and `PaletteFix.java` into "Fix palette contrast for generated accents"; `multigram/tools/sim/palette-fix/overlay_A.json` and a new `multigram_styles.bin` into "Add the style table and its generator".
1891−
1892−**3. Residual check on the finished tree.** Run the simulator on the MultiGram tree itself:
1893−
1894−```sh
1895−mkdir -p /tmp/overlay-in-tree
1896−cd multigram/tools/sim
1897−TG_REPO=$(git rev-parse --show-toplevel) CANVAS_OUT=/tmp/overlay-in-tree python3 canvas.py --build | grep '^VARIANT A'
1898−cd ../../..
1899−```
1900−
1901−On 4780eb095b it prints `VARIANT A (1.6 s): 12 defaults, 0 attheme lines, 13 exclusions` (the time varies). The 12 defaults are the on-accent marks the app keeps white on purpose. Any `.attheme` line, or a 13th default, means a grey below 4.5 : 1.
1902−
1903−**4. Measure generated accents** on the plain new Forkgram from step 2:
1904−
1905−```sh
1906−cd multigram/tools/sim
1907−TG_REPO=/tmp/fk-new python3 canvas.py --measure 200 --variant A --procs 4
1908−cd ../../..
1909−```
1910−
1911−On 12.10.6 the line `pass rate (generated, all pairs)` shows `100.00%` for all five themes, and `residual failing pairs` shows `(none)`. Use `--measure 5000` before a release.
1912−
1913−**On a device:** with a stock preset accent, only grey secondary texts differ from Forkgram. With a light runtime accent (pick light yellow with the colour wheel), marks on accent fills turn near-black: the chat list pencil, unread digits, Join buttons, checkboxes, the send button. With a dark accent they stay white. To test hook P12, install a build from before this commit, select Arctic Blue, then install the new build over it: the darker greys must appear without clearing data.
1914−
1915−#### 8.2.6 Gotchas
1916−
1917−- **Only 11 of the 23 defaults in overlay A go into `ThemeColors.java`.** The other 12 are the on-accent marks. Overlay A stores them as `#FF050505` (the rule applied to the Blue theme inside the simulator), but the app keeps them stock white and sets them at run time. Copying them would turn every stock user's pencil and badge digits near-black. The check flags it.
1918−- **The values sit right at the minimum** (4.50 to 4.56 : 1). An upstream change to a background, bubble or the accent maths causes no conflict, but can push a grey below 4.5 : 1. Re-derive the overlay after every update (step 2 above). As a second line of defence, `check_style_table.py` re-checks every generated style on the new sources.
1919−- **If upstream changes the stock value of a replaced line**, the replay conflicts on that line. Do not just keep MultiGram's value: re-derive it, or drop the line if the new stock value passes. Update the `was 0x...` comment.
1920−- **The simulator only exists from cddd62df98.** When you re-apply 5b53859682 alone, run the simulator from a full MultiGram checkout with `TG_REPO` pointing at the tree to model.
1921−- **The checker matches hook lines exactly, including their comments.** Reformatting a hook line fails the check. But it checks only 2 of the 9 MultiGram lines in `ChatActivityEnterView.java` (the `int glyph = ...` line and the `drawableInverse` line), and it does not check whether hooks sit in the right method. Check the rest by hand (10.3).
1922−- **Order matters in `PaletteFix`.** `Keys.ON_ACCENT` and `MUTED_PINS` must follow overlay A's order. The exclusions are compared as a set.
1923−- **`Theme.key_*` values are counted at run time** (`colorsCount++`), not compile-time constants. They cannot be used in `switch` statements, and `PaletteFix` reads them through a lazy holder class (`Keys`).
1924−- **`getColor(key, null, true)`** becomes ambiguous if upstream adds another three-argument `getColor`. Cast the `null` to `(boolean[])`.
1925−- **`isActiveFor` copies the private `ThemeInfo.isDefaultMainAccent()`.** If upstream changes that method, change the copy.
1926−- **Accent ids.** The rule depends on these conventions: presets have ids up to 99, the migrated old custom accent is 100, new accents get 101 and up, server and cloud accents carry a `TL_theme` in `accent.info`, and chat-theme accents have small ids. If upstream changes them, stock accents could get the rule, or generated ones could lose it.
1927−- **`EmojiThemes` has two identical `fillAccentColors` blocks.** Hook only the one in `createColors`, not the one in `getPreviewColors`.
1928−- **Hook P11's place matters:** after the accent tint and all computed colours, before the colours are copied into paints.
1929−- **Value-only edits to bundled themes reach users only through hook P12**, because upstream re-copies a theme file only when its size changes.
1930−- **Theme files use signed decimals without the `key_` prefix.** Overlay A uses unsigned numbers with the prefix. For a value with the top bit set, subtract 2^32. Write decimals; the checker ignores `#hex`. Match lines by key, because the same old value can occur on several lines.
1931−- **`chats_attachMessage` on Dark Blue is a pure grey (`#FF999999`) on purpose.** The key is accent-coloured on the light themes, so it cannot join the global exclusion list, and a pure grey is never tinted. `chat_messagePanelIcons` is excluded without a value change. Do not tidy either.
1932−- **New calls of `fillAccentColors`** are not found automatically. After an update, run the `git grep` of P13. On 4780eb095b it prints 11 lines: the method itself and 10 calls, 6 of them hooked (P7, P9, P11, P13, P16, P17). Decide for any new call.
1933−
1934−### 8.3 Style table and random style (cddd62df98 and 27d32d57b4)
1935−
1936−#### 8.3.1 What and why
1937−
1938−These two commits give every fresh install its own day and night colour style.
1939−
1940−The colours are not made on the device. Commit **cddd62df98** adds a precomputed table, `TMessagesProj/src/main/assets/multigram_styles.bin`. It holds 4,096 styles for each of the five bundled themes (Blue, Arctic Blue, Day, Dark Blue, Night), 20,480 in all. A style is an accent colour, 1 to 4 outgoing bubble colours and a wallpaper gradient of 2 to 4 colours, without a pattern. The commit also adds the Python tools that make the table and check that every style is readable. They include the simulator. This commit touches no upstream file.
1941−
1942−Commit **27d32d57b4** adds the Java side and 15 hooks in 7 upstream files:
1943−
1944−- **Stage A** (hook S1, at app start): a fresh install gets a random seed, which picks one day and one night style. The choice is stored in the preferences file `rebrand_style`.
1945−- **Stage B** (hook S3, while the `Theme` class starts): the two styles become local accents (ids 101 and up, no pattern) and are selected before the first frame.
1946−- **Chat Settings**: a "Shuffle my style" row with Undo. "Reset to defaults" returns to the generated style.
1947−- **Privacy**: generated accents are never uploaded, shared or turned into cloud themes.
1948−
1949−Installs that existed before are never restyled automatically.
1950−
1951−#### 8.3.2 New files
1952−
1953−| Path | Purpose | Upstream names it relies on |
1954−|---|---|---|
1955−| `TMessagesProj/src/main/assets/multigram_styles.bin` | The style table, format version 1: 819,384 bytes, 20,480 records. Made by `make_style_table.py`; never edit it by hand. The same sources always give the same bytes. | The theme keys "Blue", "Arctic Blue", "Day", "Dark Blue", "Night" and their night flag; `Theme.java`, `ThemeColors.java` and the five `.attheme` files it was checked against; the `ThemeAccent` fields each record fills |
1956−| `TMessagesProj/src/main/java/org/telegram/messenger/multigram/StyleTable.java` | Reads and fully verifies the table (magic, version, sizes, directory, CRC-32). `get(night, n)` returns the n-th day or night style. It never touches `Theme`, so it is safe before `Theme` exists. | `AssetManager.open()` (not `openFd()`, because the asset is compressed) |
1957−| `TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyle.java` | The logic: Stage A, Stage B, Shuffle, Undo, Reset, the privacy records, and the seed API that the knobs use. | `Theme.getActiveTheme()`, `getCurrentTheme()`, `getCurrentNightTheme()`, `setCurrentNightTheme()`, `getTheme(String)`, `saveThemeAccents(...)` (5- and 6-argument forms), `deleteThemeAccent(...)`, `selectedAutoNightType`; `ThemeInfo.getAccent(boolean)`, `themeAccents`, `themeAccentsMap`, `prevAccentId`, `currentAccentId`, `overrideWallpaper`, `assetName`, `isDark()`, `getKey()`, `isMonet()` (Forkgram only); the `ThemeAccent` colour fields; `NotificationCenter.needSetDayNightTheme`; the preference files `themeconfig` and `mainconfig`; `PaletteFix.LAST_STOCK_ACCENT_ID` |
1958−| `TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyleUi.java` | The Chat Settings side: the Shuffle row, Undo, the custom theme tile refresh, and the "stays on this device" messages. | `TextCell`, `BulletinFactory`, `EmojiThemes.createPreviewCustom(int)` and its tile emoji, `ChatThemeBottomSheet.ChatThemeItem`, `R.drawable.menu_random`, `R.raw.info`, `Utilities.globalQueue` |
1959−| `TMessagesProj/src/main/res/values/multigram_strings.xml` | Five English strings, `MultiGram*`. They are read with `Context.getString`, never through `LocaleController`. | Telegram's build turns only `strings.xml` files into its translation assets, so this separate file stays a plain resource. The names must not clash with other strings. |
1960−| `multigram/random-style/README.md` | Design document: lifecycle, preference keys, privacy, the hook table, and a 15-item device checklist. | Documentation only |
1961−| `multigram/tools/random_style_check.py` | Checks the 15 hooks and where they sit, that the feature makes no server calls and saves without upload, the strings, the asset, and the backup policy. Prints `random-style: OK`. | The landmark lines of each hook; `forkgram/SettingsBackup.kt` and its `ALLOWED_PREFS` (Forkgram only: the check crashes without this file); `BackupAgent.java`; `android:backupAgent=".BackupAgent"` in the manifest |
1962−| `multigram/tools/README.md` | The table format, how an entry is applied, the readability rule, generation, the checks, and "Replaying onto a new Forkgram release". | Documentation only |
1963−| `multigram/tools/.gitignore` | Ignores `__pycache__/`, `*.pyc` and `sim/cache/`. | None |
1964−| `multigram/tools/check.sh` | The checks CI runs after the compile (10.2). | bash and python3 |
1965−| `multigram/tools/make_style_table.py` | Makes the table. `--check` only compares. Exit status: 0 written or identical, 3 different, 4 stalled, 1 error. | Everything `style_table.py` and the simulator read |
1966−| `multigram/tools/check_style_table.py` | Checks the table's format and the readability of every entry on this tree's theme sources. Exit status 0 on PASS, 1 on FAIL. | Same as the simulator; `TG_REPO` to model another checkout |
1967−| `multigram/tools/style_table.py` | Shared code: the binary format, the generator's settings, and how overlay A is applied. | overlay A; the palette fix's exact guard line in `fillAccentColors` (hook P8) |
1968−| `multigram/tools/sim/tgsrc.py` | Reads the theme engine's inputs straight from the Java and `.attheme` sources, and caches the result. Raises `... update the simulator` when a structure changed. | Many exact forms in `Theme.java` and `ThemeColors.java`: key declarations, fallbacks, exclusions, accent presets, `fillAccentColors`, `createDefaultWallpaper`, `createDefaultColors`, `createColorKeysMap` |
1969−| `multigram/tools/sim/engine.py` | A port of Telegram's accent engine to Python. | The logic of `fillAccentColors`, `changeColorAccent`, `getAccentColor`, `useBlackText`, `changeBrightness` and others, as in Telegram 12.10.5. A change inside these methods is **not** detected; see 8.3.6. |
1970−| `multigram/tools/sim/readability.py` | The 99 readability pairs and the contrast maths. | Colour key names; models of the bubble, wallpaper and service message drawing |
1971−| `multigram/tools/sim/style_sim.py`, `multigram/tools/sim/canvas.py`, `multigram/tools/sim/detmath.py`, `multigram/tools/sim/detmath_tables.py` | The style generator, the overlay builder and measurer (`canvas.py`), and portable maths that keep results identical on every machine. | None beyond the files above |
1972−| `multigram/tools/sim/palette-fix/overlay_A.json`, `multigram/tools/sim/palette-fix/overlay_B.json` | overlay A (a copy, used when the modelled tree has none) and the rejected variant B. | Must stay byte-identical to `multigram/palette-fix/overlay_A.json` |
1973−| `multigram/tools/sim/README.md` | How the simulator works and what it reads. | Documentation only |
1974−
1975−27d32d57b4 also adds the random style step to `check.sh` and a two-line note about it to `multigram/tools/README.md`.
1976−
1977−#### 8.3.3 Hooks
1978−
1979−##### S1. Stage A: pick this install's style at first start
1980−
1981−**File:** `TMessagesProj/src/main/java/org/telegram/messenger/ApplicationLoader.java`
1982−
1983−**Where:** Class `ApplicationLoader`, method `onCreate()`: right after the second `if (applicationContext == null) { applicationContext = getApplicationContext(); }`, before `NativeLoader.initNativeLibs(...)`. In Forkgram 12.10.6 the text below starts at line 368.
1984−
1985−**Find this** (its first line occurs twice in the file, the whole block once; text from Forkgram 12.10.6):
1986−
1987−```java
1988− applicationContext = getApplicationContext();
1989− }
1990−
1991− NativeLoader.initNativeLibs(ApplicationLoader.applicationContext);
1992−```
1993−
1994−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
1995−
1996−```diff
1997− if (applicationContext == null) {
1998− applicationContext = getApplicationContext();
1999− }
2000−+ org.telegram.messenger.multigram.RandomStyle.onApplicationCreate(applicationContext); // MultiGram: per-install style seed, before anything touches Theme
2001−
2002− NativeLoader.initNativeLibs(ApplicationLoader.applicationContext);
2003−
2004−```
2005−
2006−**Why:** It runs on every start but does its work only once per install. An install that already has theme settings is marked `existing` and never restyled. A fresh install gets a random seed and picks one day and one night entry from the style table. It never throws.
2007−
2008−**If the code moved:** It must run in `Application.onCreate` after `applicationContext` is set, and **before anything loads the `Theme` class**. Theme's static initializer writes a theme setting on every start; after that, every fresh install would count as `existing` and silently get no style. Keep it the first MultiGram line in `onCreate`: K1 goes directly below it. The lines `applicationContext = getApplicationContext();` and `NativeLoader.initNativeLibs(` each occur twice in the file; the 4-line block shown occurs once. `random_style_check.py` checks the order.
2009−
2010−
2011−##### S2. Never upload a generated style
2012−
2013−**File:** `TMessagesProj/src/main/java/org/telegram/messenger/MessagesController.java`
2014−
2015−**Where:** Class `MessagesController`, method `saveThemeToServer(Theme.ThemeInfo, Theme.ThemeAccent)`: the first statement. In Forkgram 12.10.6 the text below starts at line 9131.
2016−
2017−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2018−
2019−```java
2020− public void saveThemeToServer(Theme.ThemeInfo themeInfo, Theme.ThemeAccent accent) {
2021−```
2022−
2023−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2024−
2025−```diff
2026− }
2027−
2028− public void saveThemeToServer(Theme.ThemeInfo themeInfo, Theme.ThemeAccent accent) {
2029−+ if (org.telegram.messenger.multigram.RandomStyle.isUploadBlocked(themeInfo, accent)) return; // MultiGram: a generated style never leaves the device
2030− if (themeInfo == null) {
2031− return;
2032− }
2033−```
2034−
2035−**Why:** Every theme or accent upload passes through this method. A generated accent, or an edit or copy of one, is never uploaded.
2036−
2037−**If the code moved:** Put it at the very start of whatever method builds the theme file and uploads a local accent. If upstream splits the upload into several methods, guard each one. The check requires it to be the first statement of `saveThemeToServer`.
2038−
2039−
2040−##### S3. Stage B: turn the picked entries into accents before the first frame
2041−
2042−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ActionBar/Theme.java`
2043−
2044−**Where:** Class `Theme`, static initializer: after the saved accents are loaded and the old-accent migration is committed, before the auto-night setting is read. In Forkgram 12.10.6 the text below starts at line 4279.
2045−
2046−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2047−
2048−```java
2049− oldEditorNew.commit();
2050− }
2051−
2052− selectedAutoNightType = preferences.getInt("selectedAutoNightType", Build.VERSION.SDK_INT >= 29 ? AUTO_NIGHT_TYPE_SYSTEM : AUTO_NIGHT_TYPE_NONE);
2053−```
2054−
2055−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2056−
2057−```diff
2058− oldEditor.commit();
2059− oldEditorNew.commit();
2060− }
2061−+ applyingTheme = org.telegram.messenger.multigram.RandomStyle.onThemeInit(applyingTheme); // MultiGram: a fresh install gets its generated style before the first frame
2062−
2063− selectedAutoNightType = preferences.getInt("selectedAutoNightType", Build.VERSION.SDK_INT >= 29 ? AUTO_NIGHT_TYPE_SYSTEM : AUTO_NIGHT_TYPE_NONE);
2064− autoNightScheduleByLocation = preferences.getBoolean("autoNightScheduleByLocation", false);
2065−```
2066−
2067−**Why:** On a fresh install this creates the day and night accents from the picked entries (new ids from 101 up, no pattern), saves them without uploading, selects them and returns the day theme. The stock code then applies it, so the first frame already shows the style. On every start it also tidies its records of generated accents.
2068−
2069−**If the code moved:** Place it inside the static initializer where all of these hold: (1) every bundled theme's accents are loaded (the loop with `String accents = themeConfig.getString("accents_" + info.assetName, null);`) and the old-accent migration is committed; (2) the local `applyingTheme` was read from the `theme` setting; (3) `selectedAutoNightType` is not read yet, and neither `currentDayTheme = applyingTheme;` nor the first `applyTheme(applyingTheme, false, false, switchToTheme == 2);` has run. If the local variable is renamed, assign the result to the new name. The check tests this order with the landmark patterns in `order(THEME, ...)` inside `check_placement()` of `random_style_check.py`. If upstream changes one of those landmark lines, the check fails although the hook is right (10.2).
2070−
2071−
2072−##### S4. A setter for the private day theme
2073−
2074−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ActionBar/Theme.java`
2075−
2076−**Where:** Class `Theme`, between `getCurrentTheme()` and `getCurrentNightTheme()`: a new one-line method and a blank line. In Forkgram 12.10.6 the text below starts at line 6448.
2077−
2078−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2079−
2080−```java
2081− public static ThemeInfo getCurrentNightTheme() {
2082−```
2083−
2084−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2085−
2086−```diff
2087− return currentDayTheme != null ? currentDayTheme : defaultTheme;
2088− }
2089−
2090−+ public static void setCurrentDayTheme(ThemeInfo theme) { currentDayTheme = theme; } // MultiGram: "Shuffle my style" sets the day theme while the night theme is showing
2091−+
2092− public static ThemeInfo getCurrentNightTheme() {
2093− return currentNightTheme;
2094− }
2095−```
2096−
2097−**Why:** `currentDayTheme` is private. When Shuffle, Undo or Reset runs while the night theme is showing, MultiGram records the new day style here for the next switch to day.
2098−
2099−**If the code moved:** Anywhere in class `Theme`. The check requires this exact line with 4 spaces of indentation. If upstream renames the field, keep the method's name and point it at the new field. Then change the old field name in two patterns of `multigram/tools/random_style_check.py`, both in "Give each install its own random style": the S4 entry in `HOOKS`, and the landmark `currentDayTheme = applyingTheme;` in `check_placement()`. Git often replays this hook without a conflict after such a rename, because the rename changes only other lines. Then only the check or the compile shows it.
2100−
2101−
2102−##### S5. "Create new theme" refuses the generated style
2103−
2104−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Components/AlertsCreator.java`
2105−
2106−**Where:** Class `AlertsCreator`, method `createThemeCreateDialog(BaseFragment, int, Theme.ThemeInfo, Theme.ThemeAccent)`: right after the `fragment == null` check. In Forkgram 12.10.6 the text below starts at line 8296.
2107−
2108−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2109−
2110−```java
2111− public static void createThemeCreateDialog(BaseFragment fragment, int type, Theme.ThemeInfo switchToTheme, Theme.ThemeAccent switchToAccent) {
2112−```
2113−
2114−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2115−
2116−```diff
2117− if (fragment == null || fragment.getParentActivity() == null) {
2118− return;
2119− }
2120−+ if (org.telegram.messenger.multigram.RandomStyleUi.interceptThemeCreate(fragment, switchToAccent)) return; // MultiGram: a cloud theme cannot start from the generated style
2121− Context context = fragment.getParentActivity();
2122− final EditTextBoldCursor editText = new EditTextBoldCursor(context);
2123− editText.setBackground(null);
2124−```
2125−
2126−**Why:** Creating a cloud theme would upload the accent's colours. For a generated accent the dialog is replaced by a short message that the style stays on this device.
2127−
2128−**If the code moved:** At the start of the flow that creates a cloud theme from an accent, after the fragment check (the call shows a message on the fragment). The check requires it directly after `if (fragment == null || fragment.getParentActivity() == null) { return; }`.
2129−
2130−
2131−##### S6. Rebuild the custom theme tile after Shuffle
2132−
2133−**File:** `TMessagesProj/src/main/java/org/telegram/ui/DefaultThemesPreviewCell.java`
2134−
2135−**Where:** Class `DefaultThemesPreviewCell`, method `updateDayNightMode()`: the first statement. In Forkgram 12.10.6 the text below starts at line 352.
2136−
2137−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2138−
2139−```java
2140− public void updateDayNightMode() {
2141−```
2142−
2143−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2144−
2145−```diff
2146− }
2147−
2148− public void updateDayNightMode() {
2149−+ org.telegram.messenger.multigram.RandomStyleUi.refreshCustomTile(adapter.items, parentFragment.getCurrentAccount()); // MultiGram: Shuffle, Undo and Reset replace the accents the custom tile points to
2150− if (currentType == ThemeActivity.THEME_TYPE_BASIC || currentType == TYPE_CUSTOM_LIST) {
2151− themeIndex = !Theme.isCurrentThemeDay() ? 2 : 0;
2152− } else {
2153−```
2154−
2155−**Why:** Chat Settings shows a strip of colour themes with a custom tile (the palette emoji). Shuffle, Undo and Reset replace the accents that tile points to; this rebuilds the tile so it never applies a deleted accent.
2156−
2157−**If the code moved:** Call it wherever the strip refreshes after a theme change. It finds the tile by the emoji that `EmojiThemes.createPreviewCustom` sets; if upstream changes that emoji, change `RandomStyleUi.CUSTOM_TILE_EMOJI` too. The check requires it as the first line of `updateDayNightMode`.
2158−
2159−
2160−##### S7. The accent editor marks copies of the generated style
2161−
2162−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ThemePreviewActivity.java`
2163−
2164−**Where:** Class `ThemePreviewActivity`, constructor, branch `if (screenType == SCREEN_TYPE_ACCENT_COLOR)`, right after the accent is obtained. In Forkgram 12.10.6 the text below starts at line 562.
2165−
2166−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2167−
2168−```java
2169− accent = applyingTheme.getAccent(!edit);
2170−```
2171−
2172−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2173−
2174−```diff
2175−
2176− if (screenType == SCREEN_TYPE_ACCENT_COLOR) {
2177− accent = applyingTheme.getAccent(!edit);
2178−+ org.telegram.messenger.multigram.RandomStyle.onAccentCopied(applyingTheme, accent, !edit); // MultiGram: a copy of the generated style stays on this device too
2179− if (accent != null) {
2180− useDefaultThemeForButtons = false;
2181− backupAccentColor = accent.accentColor;
2182−```
2183−
2184−**Why:** The accent editor either edits the current accent or creates a copy. If the source is a generated accent, the result is marked too, so it also stays on the device.
2185−
2186−**If the code moved:** Right after the editor gets its accent and before anything can save or upload it. The check requires it on the line directly after `accent = applyingTheme.getAccent(!edit);`.
2187−
2188−
2189−##### S8. Chat Settings: a field for the new row
2190−
2191−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ThemeActivity.java`
2192−
2193−**Where:** Class `ThemeActivity`, the row index fields. In Forkgram 12.10.6 the text below starts at line 223.
2194−
2195−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2196−
2197−```java
2198− private int createNewThemeRow;
2199−```
2200−
2201−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2202−
2203−```diff
2204− private int editThemeRow;
2205− @Keep
2206− private int createNewThemeRow;
2207−+ private int shuffleStyleRow = -1; // MultiGram: "Shuffle my style"
2208− private int lastShadowRow;
2209− @Keep
2210− private int stickersRow;
2211−```
2212−
2213−**Why:** The row index of "Shuffle my style". It is -1 when the row is not shown.
2214−
2215−**If the code moved:** Any field position works. Keep the initial value -1, because only the Chat Settings branch of `updateRows` sets it.
2216−
2217−
2218−##### S9. Chat Settings: reset the row index
2219−
2220−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ThemeActivity.java`
2221−
2222−**Where:** Class `ThemeActivity`, method `updateRows(boolean)`: the block that sets every row index to -1. In Forkgram 12.10.6 the text below starts at line 607.
2223−
2224−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2225−
2226−```java
2227− createNewThemeRow = -1;
2228−```
2229−
2230−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2231−
2232−```diff
2233− themePreviewRow = -1;
2234− editThemeRow = -1;
2235− createNewThemeRow = -1;
2236−+ shuffleStyleRow = -1; // MultiGram: "Shuffle my style"
2237−
2238− appIconHeaderRow = -1;
2239− appIconSelectorRow = -1;
2240−```
2241−
2242−**Why:** Resets the row every time the rows are rebuilt, so other screens that share this class never show it.
2243−
2244−**If the code moved:** With the other `xxxRow = -1;` lines at the top of the method that counts the rows.
2245−
2246−
2247−##### S10. Chat Settings: place the row under the colour themes
2248−
2249−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ThemeActivity.java`
2250−
2251−**Where:** Class `ThemeActivity`, method `updateRows(boolean)`, branch `else if (currentType == THEME_TYPE_BASIC)` (Chat Settings), after the colour theme strip. In Forkgram 12.10.6 the text below starts at line 663.
2252−
2253−**Find this** (its first line occurs twice in the file, the whole block once; text from Forkgram 12.10.6):
2254−
2255−```java
2256− themeListRow2 = rowCount++;
2257− themeInfoRow = rowCount++;
2258−```
2259−
2260−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2261−
2262−```diff
2263− themeHeaderRow = rowCount++;
2264−
2265− themeListRow2 = rowCount++;
2266−+ shuffleStyleRow = rowCount++; // MultiGram: under the colour themes
2267− themeInfoRow = rowCount++;
2268−
2269− bubbleRadiusHeaderRow = rowCount++;
2270−```
2271−
2272−**Why:** Shows "Shuffle my style" directly under the colour theme strip.
2273−
2274−**If the code moved:** In the branch that builds Chat Settings (`THEME_TYPE_BASIC`), right after the row that holds the theme strip. `themeListRow2 = rowCount++;` also occurs in the themes browser branch; do not use that one. The 2-line block shown occurs once. The check requires `themeListRow2 = rowCount++;` directly followed by `shuffleStyleRow = rowCount++;`.
2275−
2276−
2277−##### S11. Sharing refuses the generated style
2278−
2279−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ThemeActivity.java`
2280−
2281−**Where:** Class `ThemeActivity`, method `didReceivedNotification(int, int, Object...)`, branch `NotificationCenter.needShareTheme`, after the `isPaused` check. In Forkgram 12.10.6 the text below starts at line 910.
2282−
2283−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2284−
2285−```java
2286− } else if (id == NotificationCenter.needShareTheme) {
2287− if (getParentActivity() == null || isPaused) {
2288− return;
2289− }
2290−```
2291−
2292−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2293−
2294−```diff
2295− if (getParentActivity() == null || isPaused) {
2296− return;
2297− }
2298−+ if (org.telegram.messenger.multigram.RandomStyleUi.interceptShare(this, (Theme.ThemeAccent) args[1])) return; // MultiGram: the generated style stays on this device
2299− sharingTheme = (Theme.ThemeInfo) args[0];
2300− sharingAccent = (Theme.ThemeAccent) args[1];
2301− sharingProgressDialog = new AlertDialog(getParentActivity(), AlertDialog.ALERT_TYPE_SPINNER);
2302−```
2303−
2304−**Why:** Sharing an accent needs an upload, which S2 refuses. Without this hook a spinner would wait forever. The hook shows the "stays on this device" message instead.
2305−
2306−**If the code moved:** At the start of the handler that begins sharing a theme or accent (the one that stores `sharingTheme` and `sharingAccent` and shows the spinner), before the spinner. `args[1]` is the accent.
2307−
2308−
2309−##### S12. "Reset to defaults" also resets the colours
2310−
2311−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ThemeActivity.java`
2312−
2313−**Where:** Class `ThemeActivity`, action bar menu handler, `} else if (id == reset_settings) {`: the last statement of the confirm button's lambda. In Forkgram 12.10.6 the text below starts at line 1041.
2314−
2315−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2316−
2317−```java
2318− Theme.reloadWallpaper(true);
2319− }
2320− }
2321− });
2322−```
2323−
2324−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2325−
2326−```diff
2327− Theme.reloadWallpaper(true);
2328− }
2329− }
2330−+ org.telegram.messenger.multigram.RandomStyle.resetToGeneratedStyle(); // MultiGram: also reset the colours, to this install's generated style (the branch above needs the theme list, which Chat Settings lacks)
2331− });
2332− builder1.setNegativeButton(getString("Cancel", R.string.Cancel), null);
2333− AlertDialog alertDialog = builder1.create();
2334−```
2335−
2336−**Why:** Stock "Reset to defaults" in Chat Settings resets only text size and bubble radius. This returns the colours to the install's generated style. It does nothing when the install has no generated style, or when a custom, cloud or Monet theme is involved.
2337−
2338−**If the code moved:** Make it the last statement of the confirm handler, **outside** the `if (themesHorizontalListCell != null) { ... }` block (that cell is null in Chat Settings). K16 edits another line in the same lambda.
2339−
2340−
2341−##### S13. A tap on the row runs Shuffle
2342−
2343−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ThemeActivity.java`
2344−
2345−**Where:** Class `ThemeActivity`, method `createView(Context)`: the first statement of the list's item click listener. In Forkgram 12.10.6 the text below starts at line 1105.
2346−
2347−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2348−
2349−```java
2350− listView.setOnItemClickListener((view, position, x, y) -> {
2351−```
2352−
2353−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2354−
2355−```diff
2356− frameLayout.addView(listView, LayoutHelper.createFrame(LayoutHelper.MATCH_PARENT, LayoutHelper.MATCH_PARENT));
2357− actionBar.setAdaptiveBackground(listView);
2358− listView.setOnItemClickListener((view, position, x, y) -> {
2359−+ if (position == shuffleStyleRow) { org.telegram.messenger.multigram.RandomStyleUi.shuffle(ThemeActivity.this); return; } // MultiGram: "Shuffle my style"
2360− if (position == enableAnimationsRow) {
2361− SharedPreferences preferences = MessagesController.getGlobalMainSettings();
2362− boolean animations = preferences.getBoolean("view_animations", true);
2363−```
2364−
2365−**Why:** A tap runs Shuffle: a new seed and style, applied at once, with an Undo message when the generated style was showing.
2366−
2367−**If the code moved:** First statement of the click listener of ThemeActivity's main list. The check requires that position.
2368−
2369−
2370−##### S14. Draw the row
2371−
2372−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ThemeActivity.java`
2373−
2374−**Where:** Inner class `ThemeActivity.ListAdapter`, method `onBindViewHolder`, `case TYPE_TEXT_PREFERENCE:`, after `cell.heightDp = 48;`. In Forkgram 12.10.6 the text below starts at line 2668.
2375−
2376−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2377−
2378−```java
2379− case TYPE_TEXT_PREFERENCE: {
2380− TextCell cell = (TextCell) holder.itemView;
2381− cell.heightDp = 48;
2382−```
2383−
2384−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2385−
2386−```diff
2387− case TYPE_TEXT_PREFERENCE: {
2388− TextCell cell = (TextCell) holder.itemView;
2389− cell.heightDp = 48;
2390−+ if (position == shuffleStyleRow) { org.telegram.messenger.multigram.RandomStyleUi.bindShuffleRow(cell); break; } // MultiGram: "Shuffle my style"
2391− if (position == backgroundRow) {
2392− cell.setSubtitle(null);
2393− cell.setColors(Theme.key_windowBackgroundWhiteBlueText4, Theme.key_windowBackgroundWhiteBlueText4);
2394−```
2395−
2396−**Why:** Binds the row like "Change chat background": blue text, the dice icon `R.drawable.menu_random`, and the label `R.string.MultiGramShuffleStyle`.
2397−
2398−**If the code moved:** In the binding of the view type that creates a `TextCell` for "Change chat background". The `break` leaves the `switch` case.
2399−
2400−
2401−##### S15. Give the row its view type
2402−
2403−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ThemeActivity.java`
2404−
2405−**Where:** Inner class `ThemeActivity.ListAdapter`, method `getItemViewType(int)`: the first statement. In Forkgram 12.10.6 the text below starts at line 2732.
2406−
2407−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2408−
2409−```java
2410− if (position == scheduleFromRow || position == distanceRow ||
2411−```
2412−
2413−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2414−
2415−```diff
2416−
2417− @Override
2418− public int getItemViewType(int position) {
2419−+ if (position == shuffleStyleRow) return TYPE_TEXT_PREFERENCE; // MultiGram: "Shuffle my style"
2420− if (position == scheduleFromRow || position == distanceRow ||
2421− position == scheduleToRow || position == scheduleUpdateLocationRow ||
2422− position == contactsReimportRow || position == contactsSortRow ||
2423−```
2424−
2425−**Why:** Gives the row the `TextCell` view type, so the adapter creates and binds it.
2426−
2427−**If the code moved:** The file has two `getItemViewType(int position)` methods (in `ThemeAccentsListAdapter` and in `ListAdapter`). Use the one whose body starts with `if (position == scheduleFromRow`.
2428−
2429−
2430−#### 8.3.4 Data
2431−
2432−**The table file.** All numbers are big-endian. Size = 24 + 32 × themes + 40 × records.
2433−
2434−| Part | Size | Content |
2435−|---|---|---|
2436−| Header | 24 bytes | magic `MGST`, version 1, header size 24, theme count, directory entry size 32, record size 40, a zero, record count, CRC-32 of everything after the header |
2437−| Directory | 32 bytes per theme | theme key (20 bytes, UTF-8, padded with zeros), flags (bit 0 = night), first record, record count |
2438−| Record | 40 bytes | 9 colours (accent, bubble, bubble gradient 1 to 3, wallpaper 1 to 4), rotation (a multiple of 45), flags (bit 0 animated bubble gradient, bit 1 pattern motion), a zero. Bubble gradient 2 and 3 and wallpaper 3 and 4 are 0 when unused; bubble gradient 1 equals the bubble for a plain bubble. |
2439−
2440−The themes are, in order: Blue, Arctic Blue and Day (day), then Dark Blue and Night (night). On 4780eb095b the CRC-32 is `884B1657`. `StyleTable.java` accepts only this version and these sizes. A format change means changing `style_table.py`, `StyleTable.java` and `multigram/tools/README.md` together.
2441−
2442−**The pick.** Day index = `(mix64(seed + 1 × 0x9E3779B97F4A7C15) >>> 1) % day count`, and the night index uses 2 in place of 1.
2443−
2444−**Preferences file `rebrand_style`:** `state`, `fresh`, `seed`, `version`, `table_crc`, `day_theme`, `night_theme`, `day_style`, `night_style` (80 hex digits each), `day_index`, `night_index`, `day_accent`, `night_accent`, `generated`. The file `themeconfig` also gets `multigram_generated_accents`.
2445−
2446−#### 8.3.5 Regenerate and verify
2447−
2448−From the root of a checkout:
2449−
2450−```sh
2451−bash multigram/tools/check.sh
2452−python3 multigram/tools/random_style_check.py --base origin/forkgram
2453−python3 multigram/tools/check_style_table.py
2454−python3 multigram/tools/make_style_table.py --check
2455−```
2456−
2457−- `random_style_check.py` prints `random-style: OK`. A failure lists each problem, then `random-style: FAILED, N problem(s)`.
2458−- `check_style_table.py` ends with `style table check: PASS (0 failures, 0 warnings, ...)`. Lines starting with `note:` are expected.
2459−- `make_style_table.py --check` ends with `identical to .../multigram_styles.bin`.
2460−
2461−**When to make a new table.** Run `check.sh` after every update, once the palette fix and this unit are back:
2462−
2463−| `check.sh` says | Meaning | What to do |
2464−|---|---|---|
2465−| `FAIL: <theme>: N entries are unreadable on the current theme sources` | The new sources pushed some styles below the minimum. | Make a new table. If the generator then stalls, the palette fix must change first. |
2466−| `WARNING: <theme>: N entries lost the generator's headroom but still pass WCAG` | The styles still pass, but with less margin. | Make a new table when convenient. |
2467−| `WARNING: make_style_table.py would now generate a different table` (exit status 3) | The theme sources changed since the table was made. CI still passes. | Make a new table when convenient. |
2468−| `FAIL: the style table can no longer be generated on these theme sources (see STALLED above).` (exit status 4) | A colour the styles cannot move fell below its minimum. A new table cannot help. | Fix the palette first (8.2.5). |
2469−| `... update the simulator` | The simulator cannot read the new `Theme.java` or `ThemeColors.java`. | Update `multigram/tools/sim/tgsrc.py`. |
2470−| A Python traceback that ends in `KeyError: 'key_...'` | Upstream renamed a colour key that the simulator names. | Rename it in the simulator and the other MultiGram files (6.2, the `key_...` row). |
2471−
2472−To make a new table (about 40 seconds on 4 cores):
2473−
2474−```sh
2475−python3 multigram/tools/make_style_table.py
2476−bash multigram/tools/check.sh
2477−```
2478−
2479−Then fold the new `multigram_styles.bin` into "Add the style table and its generator" with a fixup (6.3). Installed phones keep the styles they stored. Only later Shuffles draw from the new table.
2480−
2481−**Preview a new Forkgram before replaying.** This checks 64 styles per theme against the plain new snapshot (the folder `/tmp/fk-new` from step 2 of 8.2.5). It models that tree, but reads the table from this checkout:
2482−
2483−```sh
2484−TG_REPO=/tmp/fk-new python3 multigram/tools/check_style_table.py --sample 64
2485−```
2486−
2487−On Forkgram 12.10.6 it ended with `style table check: PASS (0 failures, 0 warnings, 4.5 s)`.
2488−
2489−**On a device** (the full list is "Device test checklist" in `multigram/random-style/README.md`):
2490−
2491−1. Fresh install (or `adb shell pm clear <applicationId>`): the first frame already shows a non-stock accent and a plain gradient wallpaper, with no flash of the Blue theme. With a dark system theme (Android 10 and later), the night style shows.
2492−2. Chat Settings shows "Shuffle my style" under the colour themes. A tap changes the colours at once and offers Undo.
2493−3. Menu > "Reset to defaults" brings the install's generated style back.
2494−4. Share, or "Create new theme", on the generated accent shows the "stays on this device" message and no spinner.
2495−5. Installing over an existing install changes nothing.
2496−6. On a debuggable build, `adb shell run-as <applicationId> cat shared_prefs/rebrand_style.xml` shows `state` (`applied` for a fresh install, `existing` for an old one), a `seed`, and two styles of 80 hex digits.
2497−
2498−#### 8.3.6 Gotchas
2499−
2500−- **Order.** The palette fix must be in the tree first: `RandomStyle` uses `PaletteFix.LAST_STOCK_ACCENT_ID`, and the styles are readable only with the palette fix. The knobs (3b941c01de) come after.
2501−- **Stage A must run before anything loads `Theme`.** `Theme` writes to `themeconfig` on every start, and any key there marks the install as existing. If upstream starts to touch `Theme` earlier, every fresh install silently gets no style. Nothing crashes, so test a fresh install on a device.
2502−- **Stage B has three order rules** inside `Theme`'s static initializer: after all bundled accents are loaded (so new ids start at 101), before `selectedAutoNightType` is read, and before the first `applyTheme(...)`. `random_style_check.py` checks this order.
2503−- **`ThemeActivity.java` has two lines `themeListRow2 = rowCount++;` and two `getItemViewType` methods.** The row goes in the Chat Settings branch, and the view type in `ListAdapter`'s method (S10, S15).
2504−- **The Reset hook (S12) goes after the `if (themesHorizontalListCell != null) { ... }` block**, not inside it: that cell is null in Chat Settings.
2505−- **Every hook is one line ending in `// MultiGram: ...`**, apart from the blank line after S4. The check's placement patterns are strict. If upstream reformats the code around a hook, update `check_placement()` in `random_style_check.py` too.
2506−- **Never merge the binary table in a conflict.** Take either side, finish the source changes, run `make_style_table.py`, and commit its output.
2507−- **The simulator copies Telegram's accent maths by hand.** `tgsrc.py` fails loudly when a structure it reads changes, but a change of logic inside a method passes silently. After each update, read the upstream changes to these files and look for edits inside the engine methods listed in 8.3.2:
2508−
2509− ```sh
2510− git diff $(git merge-base origin/multigram origin/forkgram) origin/forkgram -- TMessagesProj/src/main/java/org/telegram/ui/ActionBar/Theme.java TMessagesProj/src/main/java/org/telegram/ui/ActionBar/ThemeColors.java TMessagesProj/src/main/java/org/telegram/ui/ActionBar/MessageDrawable.java TMessagesProj/src/main/java/org/telegram/messenger/AndroidUtilities.java
2511− ```
2512−
2513− The `merge-base` is the snapshot `multigram` is built on. That works until `multigram` moves to the new release. Afterwards, put `multigram-before-<version>` in place of `origin/multigram`, with the new release as `<version>`: promote puts that tag on the old tip (4.1).
2514−
2515−- **Exit status 4 (stalled)** cannot be fixed by making a new table. Exit status 3 and the headroom warning only mean the table is older than the sources.
2516−- **Forkgram-only dependencies:** `ThemeInfo.isMonet()`, and `forkgram/SettingsBackup.kt`, which the check reads. Never add `rebrand_style` to `ALLOWED_PREFS` or to `BackupAgent`: by design a seed must not follow the user to another device (see "Backup and settings export" in `multigram/random-style/README.md`).
2517−- **The custom theme tile** is found by its emoji (`RandomStyleUi.CUSTOM_TILE_EMOJI`, which must equal what `EmojiThemes.createPreviewCustom` sets). If upstream changes the emoji, the refresh silently does nothing.
2518−- **`addAccent` relies on `ThemeInfo.getAccent(true)`**: it increments `lastAccentId`, copies the current accent and sets `prevAccentId`. If upstream changes that method, re-check `addAccent`, `removeAccent` and `onAccentCopied` in `RandomStyle.java`.
2519−- **Stage B uses the 6-argument `saveThemeAccents`**, so that no notifications fire inside the static initializer. Every call in the feature must pass `false` for upload; the check enforces it.
2520−- **The five theme names are built into the table.** If upstream renames, adds or removes a bundled theme, the checks fail and Stage B leaves the stock style. Update `DAY_THEMES` and `NIGHT_THEMES` in `style_table.py`, and make a new table.
2521−- **Copying `RandomStyle.java` or `RandomStyleUi.java` from the tip** brings the knob calls with it. Re-apply the knobs (8.4) too, or the build fails on `StyleKnobs`.
2522−- **Testing the first start** needs a fresh install or cleared data. An update over an old build only tests the "existing" path. An Android backup restore counts as a fresh install.
2523−- **The simulator's cache** (`multigram/tools/sim/cache/`) is keyed by the source files. If you change `tgsrc.py`'s parsing, raise `PARSER_VERSION` or delete the cache folder.
2524−
2525−### 8.4 Style knobs and CI self-test (3b941c01de and 4780eb095b)
2526−
2527−#### 8.4.1 What and why
2528−
2529−Commit **3b941c01de** adds `StyleKnobs`. It uses the install's seed from the random style to pick shapes and a chat list layout, using only options the app already has. Each knob gets its own random numbers from `RandomStyle.deriveSeed(seed, "<knob name>")`, so adding a knob never changes the others. The values are stored in the preferences file `rebrand_style_knobs` together with the seed. So an app update never restyles an install; only a new seed (Shuffle or Undo) derives them again.
2530−
2531−Installs without a seed keep every stock value, because each hook returns the stock value it is given. Two knobs are real Forkgram settings (`bubbleRadius` and `useThreeLinesLayout`). They are written only while they still hold the stock value or the value `StyleKnobs` last wrote, so a user's own choice is never overwritten.
2532−
2533−Commit **4780eb095b** adds the rebrand self-test to `check.sh`. A Forkgram release that moves what the rebrand generator changes then fails the sync's compile job, instead of breaking the next rebrand.
2534−
2535−#### 8.4.2 New files
2536−
2537−| Path | Purpose | Upstream names it relies on |
2538−|---|---|---|
2539−| `TMessagesProj/src/main/java/org/telegram/messenger/multigram/StyleKnobs.java` | The whole feature: derives, stores and loads the knob values, writes the two settings, and holds the functions the hooks call. None of them throws, and each returns the stock value when there is no seed. | `SharedConfig.bubbleRadius`, `useThreeLinesLayout`, `fontSize`, `setUseThreeLinesLayout(boolean)`; the `mainconfig` keys `bubbleRadius`, `useThreeLinesLayout`, `fons_size`; `AndroidUtilities.dp(float)`; `Theme.createSimpleSelectorCircleDrawable` and `createSimpleSelectorRoundRectDrawable`; `RandomStyle.hasSeed()`, `getSeed()`, `deriveSeed(long, String)` |
2540−| `multigram/style-knobs/README.md` | Design document: the knobs, why each range is safe, the lifecycle, the hooks, and a 10-item device checklist. | Documentation only |
2541−| `multigram/tools/style_knobs_check.py` | Checks every knob hook (exact text, count, marker, full class name), that the stock lines are gone, the placement, and that `StyleKnobs` writes only its own settings and stays in safe ranges. Prints `style-knobs: OK`. | The paths of the 10 hooked files; the exact stock arguments and indentation of each hooked line; `public void onCreate() {` and `NativeLoader.initNativeLibs(` in `ApplicationLoader`; `reloadWallpaper` and `previousPhase = 0;` in `Theme`; `} else if (id == reset_settings) {` in `ThemeActivity` |
2542−
2543−3b941c01de also changes MultiGram's own `RandomStyle.java` and `RandomStyleUi.java` (hooks K18 to K22), `check.sh` (K23), `multigram/random-style/README.md` and `multigram/tools/README.md`. 4780eb095b changes only `check.sh` (K24).
2544−
2545−#### 8.4.3 Hooks
2546−
2547−K1 to K17 are in upstream files. K18 to K24 are in MultiGram's own files. Section 7 copies those files from each commit, so there their lines are already in place. Use the K18 to K24 blocks to review a file, or to rebuild one by hand.
2548−
2549−##### K1. Start: derive and apply this install's knobs
2550−
2551−**File:** `TMessagesProj/src/main/java/org/telegram/messenger/ApplicationLoader.java`
2552−
2553−**Where:** Class `ApplicationLoader`, method `onCreate()`: directly below S1.
2554−
2555−**Find this** (it occurs once in the file; text from the file as commit 27d32d57b4 left it):
2556−
2557−```java
2558− org.telegram.messenger.multigram.RandomStyle.onApplicationCreate(applicationContext); // MultiGram: per-install style seed, before anything touches Theme
2559−```
2560−
2561−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2562−
2563−```diff
2564− applicationContext = getApplicationContext();
2565− }
2566− org.telegram.messenger.multigram.RandomStyle.onApplicationCreate(applicationContext); // MultiGram: per-install style seed, before anything touches Theme
2567−+ org.telegram.messenger.multigram.StyleKnobs.onApplicationCreate(applicationContext); // MultiGram: per-install shapes and chat list layout, before SharedConfig and Theme read them
2568−
2569− NativeLoader.initNativeLibs(ApplicationLoader.applicationContext);
2570−
2571−```
2572−
2573−**Why:** Runs on every start. Installs without a seed return at once. Otherwise it loads (or, on a fresh install, derives) the knob values, writes the bubble radius and chat list layout settings where they still hold MultiGram's or the stock value, and copies them into `SharedConfig`. On tablets `SharedConfig` is loaded before this point, so the copy is needed there.
2574−
2575−**If the code moved:** Early in `Application.onCreate`, after `applicationContext` is set and after S1 (which creates the seed), before `Theme` or anything that draws. `style_knobs_check.py` requires it on the line directly after S1 and before `NativeLoader.initNativeLibs(`.
2576−
2577−**On plain Telegram (DrKLO):** Applies only on top of S1, because S1's line is its context.
2578−
2579−
2580−##### K2. Wallpaper gradient starts at this install's phase
2581−
2582−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ActionBar/Theme.java`
2583−
2584−**Where:** Class `Theme`, method `reloadWallpaper(boolean async)`: after the `if/else` that takes `previousPhase` from the old wallpaper, before `wallpaper = null;`. In Forkgram 12.10.6 the text below starts at line 9268.
2585−
2586−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2587−
2588−```java
2589− previousPhase = ((MotionBackgroundDrawable) wallpaper).getPhase();
2590− } else {
2591− previousPhase = 0;
2592− }
2593−```
2594−
2595−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2596−
2597−```diff
2598− } else {
2599− previousPhase = 0;
2600− }
2601−+ previousPhase = org.telegram.messenger.multigram.StyleKnobs.wallpaperPhase(previousPhase, wallpaper instanceof MotionBackgroundDrawable); // MultiGram: the gradient starts at this install's phase, not always at 0
2602− wallpaper = null;
2603− themedWallpaper = null;
2604− loadWallpaper(async);
2605−```
2606−
2607−**Why:** The generated gradient starts at the install's phase (0 to 7) when there is no old gradient to continue: the first load after start, and once after Shuffle, Undo or Reset. Otherwise the stock behaviour stays, so sending messages still turns the gradient.
2608−
2609−**If the code moved:** Find where the new wallpaper's starting phase is taken from the old wallpaper (`previousPhase`, later passed to `setPhase` in `loadWallpaper`). The hook must see the **old** wallpaper, so it goes before `wallpaper = null`. It belongs in `reloadWallpaper`, not `loadWallpaper`: `LaunchActivity` calls `loadWallpaper` directly, and that returns early when a wallpaper is already loaded.
2610−
2611−
2612−##### K3. Service pill: outer and inner corners
2613−
2614−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Cells/ChatActionCell.java`
2615−
2616−**Where:** Class `ChatActionCell`, method `drawBackground(Canvas, boolean)`, inside `if (invalidatePath) {`, where the path around service and date pills is built. In Forkgram 12.10.6 the text below starts at line 3341.
2617−
2618−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2619−
2620−```java
2621− final int corner = dp(11);
2622− final int cornerIn = dp(8);
2623−```
2624−
2625−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2626−
2627−```diff
2628− backgroundRight = 0;
2629− lineWidths.clear();
2630− final int count = textLayout == null ? 0 : textLayout.getLineCount();
2631−- final int corner = dp(11);
2632−- final int cornerIn = dp(8);
2633−+ final int corner = org.telegram.messenger.multigram.StyleKnobs.servicePillRadius(dp(11)); // MultiGram: pill corners follow this install's bubble radius
2634−+ final int cornerIn = org.telegram.messenger.multigram.StyleKnobs.servicePillRadius(dp(8)); // MultiGram: scaled with the corner
2635−
2636− int prevLineWidth = 0;
2637− for (int a = 0; a < count; a++) {
2638−```
2639−
2640−**Why:** Pill corners follow the install's bubble radius: about 0.65 of it, between 4 and 11 dp, never above stock, so pills never cross themselves. It follows the live slider value, but only on installs with a seed.
2641−
2642−**If the code moved:** Find the code that builds the rounded path around service messages and date pills (outer corner 11 dp, inner corners 8 and 6 dp, offset 3 dp). Wrap each corner constant in `servicePillRadius(stock)` and keep K4 in step.
2643−
2644−
2645−##### K4. Service pill: keep the stock side padding
2646−
2647−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Cells/ChatActionCell.java`
2648−
2649−**Where:** Same method and block as K3, just before `final int cornerRest = corner - cornerOffset;`. In Forkgram 12.10.6 the text below starts at line 3370.
2650−
2651−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2652−
2653−```java
2654− final int cornerOffset = dp(3);
2655− final int cornerInSmall = dp(6);
2656− final int cornerRest = corner - cornerOffset;
2657−```
2658−
2659−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2660−
2661−```diff
2662− int x = getMeasuredWidth() / 2;
2663− int previousLineBottom = 0;
2664−
2665−- final int cornerOffset = dp(3);
2666−- final int cornerInSmall = dp(6);
2667−+ final int cornerOffset = org.telegram.messenger.multigram.StyleKnobs.servicePillCornerOffset(dp(3), corner, dp(11)); // MultiGram: keeps the stock side padding (corner - cornerOffset) when the corner is smaller
2668−+ final int cornerInSmall = org.telegram.messenger.multigram.StyleKnobs.servicePillRadius(dp(6)); // MultiGram: scaled with the corner
2669− final int cornerRest = corner - cornerOffset;
2670−
2671− lineHeights.clear();
2672−```
2673−
2674−**Why:** The pill's side padding is `corner - cornerOffset` (8 dp in stock). The new offset keeps that padding when the corner is smaller, so the pill keeps its stock size. A negative offset (corner under 8 dp) is intended.
2675−
2676−**If the code moved:** Change the offset together with the corner. Changing only the corner makes the text touch the pill's edge.
2677−
2678−
2679−##### K5. Floating button: shadow follows the shape
2680−
2681−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Components/FragmentFloatingButton.java`
2682−
2683−**Where:** Class `FragmentFloatingButton`, constructor, `if (!isSubButton)` block. In Forkgram 12.10.6 the text below starts at line 73.
2684−
2685−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2686−
2687−```java
2688− setOutlineProvider(ViewOutlineProviderImpl.BOUNDS_OVAL);
2689−```
2690−
2691−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2692−
2693−```diff
2694−
2695− ScaleStateListAnimator.apply(this);
2696− if (!isSubButton) {
2697−- setOutlineProvider(ViewOutlineProviderImpl.BOUNDS_OVAL);
2698−+ setOutlineProvider(android.view.ViewOutlineProvider.BACKGROUND); // MultiGram: the shadow follows the background, a circle or this install's rounded square (stock: BOUNDS_OVAL)
2699− setTranslationZ(dpf2(0.5f));
2700− }
2701−
2702−```
2703−
2704−**Why:** The shadow follows the background: a circle (as in stock) or the install's rounded square.
2705−
2706−**If the code moved:** Wherever the main chat list button sets its outline, make the outline follow the background. The `ViewOutlineProviderImpl` import becomes unused; leave it.
2707−
2708−
2709−##### K6. Floating button: the small button above it
2710−
2711−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Components/FragmentFloatingButton.java`
2712−
2713−**Where:** Class `FragmentFloatingButton`, method `updateColors()`, branch `if (isSubButton)`. In Forkgram 12.10.6 the text below starts at line 160.
2714−
2715−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2716−
2717−```java
2718− int rad = dp(18);
2719− int pressedColor = Theme.getColor(Theme.key_listSelector, resourcesProvider);
2720−```
2721−
2722−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2723−
2724−```diff
2725− invalidate();
2726−
2727−- int rad = dp(18);
2728−+ int rad = org.telegram.messenger.multigram.StyleKnobs.floatingSubButtonRadius(dp(18)); // MultiGram: same shape as the main button
2729−+ iBlur3Background.setRadius(rad); // MultiGram: the constructor's dp(18), updated after a Shuffle
2730− int pressedColor = Theme.getColor(Theme.key_listSelector, resourcesProvider);
2731− setBackground(Theme.createInsetRoundRectDrawable(pressedColor, rad, dp(6)));
2732−```
2733−
2734−**Why:** The small button gets the main button's shape, scaled by 36/48. Its blurred background (radius set once in the constructor) is updated here, so a Shuffle changes it without rebuilding the view.
2735−
2736−**If the code moved:** Apply the same radius to the selector and to `iBlur3Background`, somewhere that runs again on a theme change. It relies on `BlurredBackgroundDrawable.setRadius(float)`.
2737−
2738−
2739−##### K7. Floating button: circle or rounded square
2740−
2741−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Components/FragmentFloatingButton.java`
2742−
2743−**Where:** Class `FragmentFloatingButton`, method `updateColors()`, the `else` branch (main button). In Forkgram 12.10.6 the text below starts at line 166.
2744−
2745−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2746−
2747−```java
2748− setBackground(Theme.createSimpleSelectorCircleDrawable(dp(48),
2749−```
2750−
2751−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2752−
2753−```diff
2754− imageView.setColorFilter(Theme.getColor(Theme.key_chats_actionIcon, resourcesProvider), PorterDuff.Mode.SRC_IN);
2755− progressView.setProgressColor(Theme.getColor(Theme.key_chats_actionIcon, resourcesProvider));
2756−- setBackground(Theme.createSimpleSelectorCircleDrawable(dp(48),
2757−+ setBackground(org.telegram.messenger.multigram.StyleKnobs.floatingButtonBackground(dp(48), // MultiGram: a circle or this install's rounded square (stock: Theme.createSimpleSelectorCircleDrawable)
2758− Theme.getColor(Theme.key_featuredStickers_addButton, resourcesProvider),
2759− Theme.getColor(Theme.key_featuredStickers_addButtonPressed, resourcesProvider)
2760−```
2761−
2762−**Why:** Returns the stock circle or a rounded square (14, 16 or 18 dp) with the same colours. It runs on every theme update, so Shuffle changes it at once.
2763−
2764−**If the code moved:** Find where the main floating button's round background is created (a 48 dp circle with `key_featuredStickers_addButton`) and route it through `floatingButtonBackground(size, color, pressedColor)`.
2765−
2766−
2767−##### K8. Premium gradient button keeps 8 dp corners
2768−
2769−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Components/Premium/boosts/GradientButtonWithCounterView.java`
2770−
2771−**Where:** Class `GradientButtonWithCounterView`, constructor: the last statement. In Forkgram 12.10.6 the text below starts at line 28.
2772−
2773−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2774−
2775−```java
2776− flickerDrawable.repeatProgress = 4f;
2777− }
2778−```
2779−
2780−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2781−
2782−```diff
2783− flickerDrawable.animationSpeedScale = 1.2f;
2784− flickerDrawable.drawFrame = false;
2785− flickerDrawable.repeatProgress = 4f;
2786−+ setRoundRadius(8); // MultiGram: stock 8dp corners, which the gradient and shimmer below are drawn with (StyleKnobs.ctaRadiusDp would change only the background and ripple)
2787− }
2788−
2789− @Override
2790−```
2791−
2792−**Why:** This button draws its gradient and shimmer with a fixed 8 dp corner. Without this line its inherited background (K13) would not match the gradient.
2793−
2794−**If the code moved:** Any subclass of `ButtonWithCounterView` that draws its own shape at a fixed radius needs `setRoundRadius(<that radius>)` at the end of its constructor. Check with `git grep -n 'extends ButtonWithCounterView'`. Today: `FoundStickerPackButton` calls `setRound()` and is fine; `UpdateReactionsButton` draws nothing of its own; `StickerCutOutBtn` keeps its own 8 dp selector while its loading shimmer follows the knob (not handled; check on a device).
2795−
2796−
2797−##### K9. Reactions: particle clip
2798−
2799−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Components/Reactions/ReactionsLayoutInBubble.java`
2800−
2801−**Where:** Inner class `ReactionsLayoutInBubble.ReactionButton`, method `drawOverlay(...)`: the particle clip. In Forkgram 12.10.6 the text below starts at line 997.
2802−
2803−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2804−
2805−```java
2806− AndroidUtilities.rectTmp.set(x, y, x + width, y + height);
2807− float rad = height / 2f;
2808−```
2809−
2810−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2811−
2812−```diff
2813− if (!LiteMode.isEnabled(LiteMode.FLAG_ANIMATED_EMOJI_REACTIONS) || !LiteMode.isEnabled(LiteMode.FLAG_PARTICLES)) return false;
2814−
2815− AndroidUtilities.rectTmp.set(x, y, x + width, y + height);
2816−- float rad = height / 2f;
2817−+ float rad = org.telegram.messenger.multigram.StyleKnobs.reactionChipRadius(height / 2f); // MultiGram: pill or this install's rounded chip
2818−
2819− particles.bounds.set(AndroidUtilities.rectTmp);
2820− particles.bounds.inset(-dp(4), -dp(4));
2821−```
2822−
2823−**Why:** Selection particles are clipped to the same chip shape as K10 draws.
2824−
2825−**If the code moved:** The line `float rad = height / 2f;` occurs twice in the file. This one follows `rectTmp.set(x, y, x + width, y + height)`. Both must use the same radius.
2826−
2827−
2828−##### K10. Reactions: chip shape
2829−
2830−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Components/Reactions/ReactionsLayoutInBubble.java`
2831−
2832−**Where:** Inner class `ReactionsLayoutInBubble.ReactionButton`, method `draw(...)`: the chip background. In Forkgram 12.10.6 the text below starts at line 1109.
2833−
2834−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2835−
2836−```java
2837− canvas.scale(bounceScale, bounceScale, x + w / 2f, y + height / 2f);
2838− }
2839− float rad = height / 2f;
2840−```
2841−
2842−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2843−
2844−```diff
2845− canvas.save();
2846− canvas.scale(bounceScale, bounceScale, x + w / 2f, y + height / 2f);
2847− }
2848−- float rad = height / 2f;
2849−+ float rad = org.telegram.messenger.multigram.StyleKnobs.reactionChipRadius(height / 2f); // MultiGram: pill or this install's rounded chip
2850− if (getDrawServiceShaderBackground() > 0 && !drawBgOnlyIfChosen) {
2851− Paint paint1 = Theme.getThemePaint(Theme.key_paint_chatActionBackground, resourcesProvider);
2852− Paint paint2 = Theme.getThemePaint(Theme.key_paint_chatActionBackgroundDarken, resourcesProvider);
2853−```
2854−
2855−**Why:** Returns the stock pill radius, or a rounded chip of about 0.6 of the bubble radius (6 to 10 dp). Saved-message tags keep their notched shape.
2856−
2857−**If the code moved:** Find where the reaction chip under messages gets its corner radius (stock: half its height) and wrap it in `reactionChipRadius(stock)`.
2858−
2859−
2860−##### K11. Settings cards: corner radius (RecyclerListView)
2861−
2862−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Components/RecyclerListView.java`
2863−
2864−**Where:** Class `RecyclerListView`, the default overloads `setSections()` and `setSections(boolean topPadding)`. In Forkgram 12.10.6 the text below starts at line 3309.
2865−
2866−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2867−
2868−```java
2869− public void setSections() {
2870− setSections(dp(12), dp(16), false);
2871− }
2872−```
2873−
2874−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2875−
2876−```diff
2877− }
2878−
2879− public void setSections() {
2880−- setSections(dp(12), dp(16), false);
2881−+ setSections(dp(12), org.telegram.messenger.multigram.StyleKnobs.sectionRadius(dp(16)), false); // MultiGram: this install's card corners
2882− }
2883− public void setSections(boolean topPadding) {
2884−- setSections(dp(12), dp(16), topPadding);
2885−+ setSections(dp(12), org.telegram.messenger.multigram.StyleKnobs.sectionRadius(dp(16)), topPadding); // MultiGram: this install's card corners
2886− }
2887− public void setSections(int padding, float roundRadius, boolean topPadding) {
2888− setSections(
2889−```
2890−
2891−**Why:** Sets the default corner radius of settings cards to 12, 14, 16 or 20 dp (stock 16). A screen reads it when it builds its list, so after a Shuffle each screen changes the next time it opens.
2892−
2893−**If the code moved:** Wrap the default card radius in `sectionRadius(stock)`. Calls that pass their own radius are left alone (there are none today). Find new copies with `git grep -n 'setSections(dp(12), dp(16)'`.
2894−
2895−
2896−##### K12. Settings cards: corner radius (UniversalRecyclerView)
2897−
2898−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Components/UniversalRecyclerView.java`
2899−
2900−**Where:** Class `UniversalRecyclerView`, its own copies of the two default `setSections` overloads. In Forkgram 12.10.6 the text below starts at line 430.
2901−
2902−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2903−
2904−```java
2905− public void setSections() {
2906− setSections(dp(12), dp(16), false);
2907− }
2908−```
2909−
2910−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2911−
2912−```diff
2913− }
2914−
2915− public void setSections() {
2916−- setSections(dp(12), dp(16), false);
2917−+ setSections(dp(12), org.telegram.messenger.multigram.StyleKnobs.sectionRadius(dp(16)), false); // MultiGram: this install's card corners, as in RecyclerListView
2918− }
2919− public void setSections(boolean topPadding) {
2920−- setSections(dp(12), dp(16), topPadding);
2921−+ setSections(dp(12), org.telegram.messenger.multigram.StyleKnobs.sectionRadius(dp(16)), topPadding); // MultiGram: this install's card corners, as in RecyclerListView
2922− }
2923− public void setSections(int padding, float roundRadius, boolean topPadding) {
2924− super.setSections(
2925−```
2926−
2927−**Why:** `UniversalRecyclerView` repeats both default overloads with its own `dp(16)`. It is used by the main Settings screen, Forkgram's settings and others, so hooking `RecyclerListView` alone is not enough.
2928−
2929−**If the code moved:** Every class that repeats the default overloads with a fixed radius needs the same change. `git grep -n 'setSections(dp(12), dp(16)'` should print nothing on a finished tree.
2930−
2931−
2932−##### K13. Buttons: default corner radius
2933−
2934−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Stories/recorder/ButtonWithCounterView.java`
2935−
2936−**Where:** Class `ButtonWithCounterView`, the field `radiusDp`. In Forkgram 12.10.6 the text below starts at line 44.
2937−
2938−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2939−
2940−```java
2941− private int radiusDp = 8;
2942−```
2943−
2944−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2945−
2946−```diff
2947−
2948− private Theme.ResourcesProvider resourcesProvider;
2949−
2950−- private int radiusDp = 8;
2951−+ private int radiusDp = org.telegram.messenger.multigram.StyleKnobs.ctaRadiusDp(8); // MultiGram: this install's button corners (explicit setRoundRadius/setRound still win)
2952−
2953− private final Paint paint;
2954− public final AnimatedTextView.AnimatedTextDrawable text;
2955−```
2956−
2957−**Why:** The default corner radius of the filled buttons in sheets and many screens: 50, 75 or 100 % of the bubble radius, between 6 and 12 dp. Buttons that call `setRound()` or `setRoundRadius(n)` keep their values.
2958−
2959−**If the code moved:** Find the default corner radius of this shared button. It must stay a field initialiser (or be set before the constructor's first `setBackground`), so the constructor's background uses it too.
2960−
2961−
2962−##### K14. Buttons: the constructor uses the field
2963−
2964−**File:** `TMessagesProj/src/main/java/org/telegram/ui/Stories/recorder/ButtonWithCounterView.java`
2965−
2966−**Where:** Class `ButtonWithCounterView`, constructor, `if (filled)`. In Forkgram 12.10.6 the text below starts at line 115.
2967−
2968−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2969−
2970−```java
2971− setBackground(Theme.createRoundRectDrawable(dp(8), backgroundColor = Theme.getColor(Theme.key_featuredStickers_addButton, resourcesProvider)));
2972−```
2973−
2974−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
2975−
2976−```diff
2977− addView(rippleView, LayoutHelper.createFrame(LayoutHelper.MATCH_PARENT, LayoutHelper.MATCH_PARENT));
2978−
2979− if (filled) {
2980−- setBackground(Theme.createRoundRectDrawable(dp(8), backgroundColor = Theme.getColor(Theme.key_featuredStickers_addButton, resourcesProvider)));
2981−+ setBackground(Theme.createRoundRectDrawable(dp(radiusDp), backgroundColor = Theme.getColor(Theme.key_featuredStickers_addButton, resourcesProvider))); // MultiGram: radiusDp (stock: dp(8), the same default)
2982− }
2983−
2984− paint = new Paint(Paint.ANTI_ALIAS_FLAG);
2985−```
2986−
2987−**Why:** The constructor had a fixed `dp(8)` instead of the field. Using `radiusDp` makes the first background match the ripple and loading shimmer. Stock behaviour is the same, because the stock default is 8.
2988−
2989−**If the code moved:** Any place in this class that draws the default shape with a literal 8 instead of `radiusDp` needs the same change.
2990−
2991−
2992−##### K15. Chat Settings: rebind the rows the knobs change
2993−
2994−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ThemeActivity.java`
2995−
2996−**Where:** Class `ThemeActivity`: a new public one-line method (and a blank line) before `setBubbleRadius(int, boolean)`. In Forkgram 12.10.6 the text below starts at line 463.
2997−
2998−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
2999−
3000−```java
3001− private boolean setBubbleRadius(int size, boolean layout) {
3002−```
3003−
3004−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
3005−
3006−```diff
3007− updateRows(true);
3008− }
3009−
3010−+ public void refreshStyleKnobRows() { if (listAdapter != null) { for (int row : new int[] {textSizeRow, bubbleRadiusRow, chatListRow}) { if (row >= 0) listAdapter.notifyItemChanged(row); } } } // MultiGram: Shuffle and Undo change the bubble radius and chat list layout these rows show (StyleKnobs); the rebind updates their cells (onBindViewHolder)
3011−+
3012− private boolean setBubbleRadius(int size, boolean layout) {
3013− if (size != SharedConfig.bubbleRadius) {
3014− SharedConfig.bubbleRadius = size;
3015−```
3016−
3017−**Why:** After Shuffle and Undo, `RandomStyleUi` calls this. It asks the list to rebind the text size preview, the message corners slider and the chat list picker, so they show the new radius and layout.
3018−
3019−**If the code moved:** It must be public (it is called from another package). It relies on the fields `textSizeRow`, `bubbleRadiusRow`, `chatListRow` and `listAdapter`. Place it anywhere in the outer class; the check wants the one-line form.
3020−
3021−
3022−##### K16. "Reset to defaults" uses this install's radius
3023−
3024−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ThemeActivity.java`
3025−
3026−**Where:** Class `ThemeActivity`, the `reset_settings` confirm lambda (the same lambda as S12). In Forkgram 12.10.6 the text below starts at line 1013.
3027−
3028−**Find this** (it occurs once in the file; text from Forkgram 12.10.6):
3029−
3030−```java
3031− if (setBubbleRadius(17, true)) {
3032−```
3033−
3034−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
3035−
3036−```diff
3037− if (setFontSize(AndroidUtilities.isTablet() ? 18 : 16)) {
3038− changed = true;
3039− }
3040−- if (setBubbleRadius(17, true)) {
3041−+ if (setBubbleRadius(org.telegram.messenger.multigram.StyleKnobs.resetBubbleRadius(17), true)) { // MultiGram: this install's radius, not always 17
3042− changed = true;
3043− }
3044− if (changed) {
3045−```
3046−
3047−**Why:** Reset returns the bubble radius to the install's value instead of 17, and marks it as MultiGram's again. With no seed it returns 17.
3048−
3049−**If the code moved:** Find the Chat Settings reset of the bubble radius to 17 and pass 17 through `resetBubbleRadius`. The check requires it inside the `} else if (id == reset_settings) {` branch.
3050−
3051−
3052−##### K17. Chat Settings: bind the three knob rows
3053−
3054−**File:** `TMessagesProj/src/main/java/org/telegram/ui/ThemeActivity.java`
3055−
3056−**Where:** Inner class `ThemeActivity.ListAdapter`, method `onBindViewHolder(RecyclerView.ViewHolder, int)`: the first case of the `switch`. In Forkgram 12.10.6 the text below starts at line 2471.
3057−
3058−**Find this** (its first line occurs twice in the file, the whole block once; text from Forkgram 12.10.6):
3059−
3060−```java
3061− public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
3062− switch (holder.getItemViewType()) {
3063−```
3064−
3065−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
3066−
3067−```diff
3068− @Override
3069− public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
3070− switch (holder.getItemViewType()) {
3071−+ case TYPE_TEXT_SIZE: case TYPE_BUBBLE_RADIUS: case TYPE_CHAT_LIST: org.telegram.messenger.multigram.RandomStyleUi.bindStyleKnobRow(holder.itemView); break; // MultiGram: these cells read the bubble radius and chat list layout only when created or measured; Shuffle and Undo change them (StyleKnobs)
3072− case TYPE_TEXT_SETTING: {
3073− TextSettingsCell cell = (TextSettingsCell) holder.itemView;
3074− if (position == nightThemeRow) {
3075−```
3076−
3077−**Why:** In stock these three view types are never bound: their cells read the settings only when created or measured. This brings a visible cell up to date after Shuffle or Undo.
3078−
3079−**If the code moved:** `TYPE_TEXT_SIZE`, `TYPE_BUBBLE_RADIUS` and `TYPE_CHAT_LIST` are private to `ListAdapter`, so the line must be inside `ListAdapter`. The file has two `onBindViewHolder` methods; the 2-line block shown occurs once. If a future version adds its own `case` for one of these types, call `bindStyleKnobRow` from that case instead: duplicate case labels do not compile.
3080−
3081−
3082−##### K18. RandomStyle.shuffle: apply the knobs before the switch
3083−
3084−**File:** `TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyle.java`
3085−
3086−**Where:** MultiGram's own file. Method `shuffle(Context)`, just before `show(pair);`.
3087−
3088−**Find this** (it occurs once in the file; text from the file as commit 27d32d57b4 left it):
3089−
3090−```java
3091− undo = previous;
3092− show(pair);
3093−```
3094−
3095−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
3096−
3097−```diff
3098− previous.shuffledSeed = seed;
3099− }
3100− undo = previous;
3101−+ StyleKnobs.onStyleApplying(seed); // before the theme switch, which reloads the wallpaper at once
3102− show(pair);
3103− notifyListeners(seed, REASON_SHUFFLE);
3104− return true;
3105−```
3106−
3107−**Why:** Derives every knob from the new seed and applies the two settings before the theme switch. The switch reloads the wallpaper at once, so K2 must already see the new phase.
3108−
3109−The commit also adds two lines to the class description at the top of the file, after the list item that ends in `(stock Chat Settings only resets text size and bubble radius).</li>`:
3110−
3111−```diff
3112−+ * <li>{@link StyleKnobs} (shapes and chat list layout from the same seed) is updated right before Shuffle, Undo and
3113−+ * Reset show the style, because the theme switch reloads the wallpaper synchronously; listeners run after.</li>
3114−```
3115−
3116−**If the code moved:** Keep the call directly before `show(pair);`; `style_knobs_check.py` checks it. A `RandomStyle.Listener` would be too late, because listeners run after `show`.
3117−
3118−
3119−##### K19. RandomStyle.undoShuffle: same
3120−
3121−**File:** `TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyle.java`
3122−
3123−**Where:** MultiGram's own file. Method `undoShuffle()`, just before `show(pair);`.
3124−
3125−**Find this** (it occurs once in the file; text from the file as commit 27d32d57b4 left it):
3126−
3127−```java
3128− putPair(editor, pair).commit();
3129− show(pair);
3130− notifyListeners(u.seed, REASON_UNDO);
3131−```
3132−
3133−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
3134−
3135−```diff
3136− putStyle(editor, DAY, u.day, u.dayIndex);
3137− putStyle(editor, NIGHT, u.night, u.nightIndex);
3138− putPair(editor, pair).commit();
3139−+ StyleKnobs.onStyleApplying(u.seed);
3140− show(pair);
3141− notifyListeners(u.seed, REASON_UNDO);
3142− return true;
3143−```
3144−
3145−**Why:** Undo restores the previous seed, and the knobs follow it.
3146−
3147−**If the code moved:** Directly before `show(pair);`, with the restored seed `u.seed`.
3148−
3149−
3150−##### K20. RandomStyle.resetToGeneratedStyle: same
3151−
3152−**File:** `TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyle.java`
3153−
3154−**Where:** MultiGram's own file. Method `resetToGeneratedStyle()`, just before `show(pair);`.
3155−
3156−**Find this** (it occurs once in the file; text from the file as commit 27d32d57b4 left it):
3157−
3158−```java
3159− putPair(p.edit(), pair).commit();
3160− show(pair);
3161−```
3162−
3163−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
3164−
3165−```diff
3166− return false;
3167− }
3168− putPair(p.edit(), pair).commit();
3169−+ StyleKnobs.onStyleApplying(p.getLong(KEY_SEED, 0));
3170− show(pair);
3171− notifyListeners(p.getLong(KEY_SEED, 0), REASON_RESET);
3172− return true;
3173−```
3174−
3175−**Why:** Reset keeps the seed; this applies the two settings again and restarts the wallpaper at the install's phase.
3176−
3177−**If the code moved:** Directly before `show(pair);`, with `p.getLong(KEY_SEED, 0)`.
3178−
3179−
3180−##### K21. RandomStyleUi: imports
3181−
3182−**File:** `TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyleUi.java`
3183−
3184−**Where:** MultiGram's own file. The import block.
3185−
3186−**Find this** (it occurs once in the file; text from the file as commit 27d32d57b4 left it):
3187−
3188−```java
3189−import android.os.SystemClock;
3190−```
3191−
3192−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
3193−
3194−```diff
3195− package org.telegram.messenger.multigram;
3196−
3197− import android.os.SystemClock;
3198−+import android.view.View;
3199−+import android.view.ViewGroup;
3200−
3201− import org.telegram.messenger.ApplicationLoader;
3202− import org.telegram.messenger.FileLog;
3203−+import org.telegram.messenger.MessageObject;
3204− import org.telegram.messenger.R;
3205−+import org.telegram.messenger.SharedConfig;
3206− import org.telegram.messenger.Utilities;
3207− import org.telegram.ui.ActionBar.BaseFragment;
3208− import org.telegram.ui.ActionBar.EmojiThemes;
3209− import org.telegram.ui.ActionBar.Theme;
3210−+import org.telegram.ui.Cells.ChatListCell;
3211−+import org.telegram.ui.Cells.ChatMessageCell;
3212− import org.telegram.ui.Cells.TextCell;
3213−+import org.telegram.ui.Cells.ThemePreviewMessagesCell;
3214− import org.telegram.ui.Components.BulletinFactory;
3215− import org.telegram.ui.Components.ChatThemeBottomSheet;
3216−+import org.telegram.ui.Components.RadioButton;
3217− import org.telegram.ui.DialogsActivity;
3218−+import org.telegram.ui.ThemeActivity;
3219−
3220−+import java.util.ArrayList;
3221− import java.util.List;
3222−
3223− /**
3224−```
3225−
3226−**Why:** Imports for K22.
3227−
3228−**If the code moved:** MultiGram's own file: copy it from the commit. The upstream classes these imports name are listed in K22.
3229−
3230−
3231−##### K22. RandomStyleUi: refresh after Shuffle and Undo, and the bind helper
3232−
3233−**File:** `TMessagesProj/src/main/java/org/telegram/messenger/multigram/RandomStyleUi.java`
3234−
3235−**Where:** MultiGram's own file. Method `shuffle(BaseFragment)` (two calls), and three new methods before the javadoc of the `DefaultThemesPreviewCell.updateDayNightMode` hook.
3236−
3237−**Find this** (it occurs once in the file; text from the file as commit 27d32d57b4 left it):
3238−
3239−```java
3240− if (fragment != null && RandomStyle.canUndoShuffle()) {
3241−```
3242−
3243−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
3244−
3245−```diff
3246− }
3247− return;
3248− }
3249−+ refreshStyleKnobRows(fragment);
3250− if (fragment != null && RandomStyle.canUndoShuffle()) {
3251− BulletinFactory.of(fragment).createUndoBulletin(str(R.string.MultiGramStyleShuffled), () -> {
3252− if (!RandomStyle.undoShuffle()) {
3253− BulletinFactory.of(fragment).createErrorBulletin(str(R.string.MultiGramUndoFailed)).show();
3254−+ } else {
3255−+ refreshStyleKnobRows(fragment);
3256− }
3257− }, null).show();
3258− }
3259− }
3260−
3261−+ /**
3262−+ * Shuffle and Undo may change the bubble radius and the chat list layout ({@link StyleKnobs}); Chat Settings
3263−+ * rebinds the rows that show them ({@link #bindStyleKnobRow}).
3264−+ */
3265−+ private static void refreshStyleKnobRows(BaseFragment fragment) {
3266−+ try {
3267−+ if (fragment instanceof ThemeActivity) {
3268−+ ((ThemeActivity) fragment).refreshStyleKnobRows();
3269−+ }
3270−+ } catch (Throwable e) {
3271−+ FileLog.e(e);
3272−+ }
3273−+ }
3274−+
3275−+ /**
3276−+ * ThemeActivity.ListAdapter.onBindViewHolder hook for the text size preview, the message corners slider and the
3277−+ * chat list picker. Their cells read the bubble radius and chat list layout only when created (the picker's
3278−+ * radio buttons) or measured (the slider's position, the preview's bubbles), and binding them does nothing in
3279−+ * stock, so after Shuffle or Undo ({@link ThemeActivity#refreshStyleKnobRows}) a visible row, or one kept
3280−+ * off screen, would still show the old values. This brings the cell in line with SharedConfig the way the
3281−+ * stock slider does: the preview messages are laid out again, the cell is measured again, and the picker's
3282−+ * radio buttons are re-checked. A cell never laid out was just created from the current values and is left
3283−+ * alone, and so is every cell while the install has no style knobs. Never throws.
3284−+ */
3285−+ public static void bindStyleKnobRow(View itemView) {
3286−+ try {
3287−+ if (itemView == null || !StyleKnobs.isActive() || itemView.getWidth() == 0) {
3288−+ return;
3289−+ }
3290−+ if (itemView instanceof ChatListCell) {
3291−+ // Its two options in order (two lines, three lines), each with one radio button; a button animates
3292−+ // only while attached, and does nothing when it already shows the value.
3293−+ List<RadioButton> buttons = new ArrayList<>();
3294−+ collect(itemView, RadioButton.class, buttons);
3295−+ if (buttons.size() == 2) {
3296−+ buttons.get(0).setChecked(!SharedConfig.useThreeLinesLayout, true);
3297−+ buttons.get(1).setChecked(SharedConfig.useThreeLinesLayout, true);
3298−+ }
3299−+ } else {
3300−+ List<ThemePreviewMessagesCell> previews = new ArrayList<>();
3301−+ collect(itemView, ThemePreviewMessagesCell.class, previews);
3302−+ for (ThemePreviewMessagesCell preview : previews) {
3303−+ ChatMessageCell[] cells = preview.getCells();
3304−+ for (int i = 0; cells != null && i < cells.length; i++) {
3305−+ MessageObject message = cells[i] == null ? null : cells[i].getMessageObject();
3306−+ if (message != null) {
3307−+ message.resetLayout();
3308−+ cells[i].requestLayout();
3309−+ }
3310−+ }
3311−+ }
3312−+ itemView.requestLayout(); // the corners slider takes its position from SharedConfig in onMeasure
3313−+ }
3314−+ itemView.invalidate();
3315−+ } catch (Throwable e) {
3316−+ FileLog.e(e);
3317−+ }
3318−+ }
3319−+
3320−+ /** Adds view and its descendants that are instances of type to out, depth first. */
3321−+ private static <T> void collect(View view, Class<T> type, List<T> out) {
3322−+ if (type.isInstance(view)) {
3323−+ out.add(type.cast(view));
3324−+ }
3325−+ if (view instanceof ViewGroup) {
3326−+ ViewGroup group = (ViewGroup) view;
3327−+ for (int i = 0; i < group.getChildCount(); i++) {
3328−+ collect(group.getChildAt(i), type, out);
3329−+ }
3330−+ }
3331−+ }
3332−+
3333− /**
3334− * DefaultThemesPreviewCell.updateDayNightMode hook. The custom tile of Chat Settings' theme strip is built once,
3335− * from themeconfig's lastDay/DarkCustomTheme(AccentId), but Shuffle, Undo and Reset replace the accents it points
3336−```
3337−
3338−**Why:** After Shuffle, and after a successful Undo, Chat Settings rebinds its three knob rows (K15). `bindStyleKnobRow` is the body of K17: it re-checks the chat list picker's radio buttons, lays the preview messages out again and re-measures the slider.
3339−
3340−**If the code moved:** MultiGram's own file. The check requires exactly two `refreshStyleKnobRows(fragment);` calls. Upstream names it relies on: `ThemeActivity.refreshStyleKnobRows` (K15), `BaseFragment`, `ChatListCell` (two options, one `RadioButton` each, in the order two lines, three lines), `RadioButton.setChecked(boolean, boolean)`, `ThemePreviewMessagesCell.getCells()`, `ChatMessageCell.getMessageObject()`, `MessageObject.resetLayout()`, and the slider cell reading `SharedConfig.bubbleRadius` in `onMeasure`. If `ChatListCell` gains a third option, the `buttons.size() == 2` guard makes this do nothing; adjust it.
3341−
3342−
3343−##### K23. check.sh: run the knobs check
3344−
3345−**File:** `multigram/tools/check.sh`
3346−
3347−**Where:** MultiGram's own file. After the random style block.
3348−
3349−**Find this** (it occurs once in the file; text from the file as commit 27d32d57b4 left it):
3350−
3351−```sh
3352− python3 multigram/tools/random_style_check.py
3353−fi
3354−```
3355−
3356−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
3357−
3358−```diff
3359− python3 multigram/tools/random_style_check.py
3360− fi
3361−
3362−+if [ -f multigram/tools/style_knobs_check.py ]; then
3363−+ # Per-install shapes and chat list layout: hooks and their placement, owned settings only, safe ranges.
3364−+ echo "== style knobs: hooks, owned settings and safe ranges"
3365−+ python3 multigram/tools/style_knobs_check.py
3366−+fi
3367−+
3368− echo "== style table: format and readability on the current theme sources"
3369− python3 multigram/tools/check_style_table.py
3370−
3371−```
3372−
3373−**Why:** CI runs `style_knobs_check.py` on every build and every sync candidate.
3374−
3375−**If the code moved:** Order does not matter. The `[ -f ... ]` guard lets older trees without the file pass.
3376−
3377−
3378−##### K24. check.sh: run the rebrand self-test (commit 4780eb095b)
3379−
3380−**File:** `multigram/tools/check.sh`
3381−
3382−**Where:** MultiGram's own file. After the style knobs block, before the style table checks.
3383−
3384−**Find this** (it occurs once in the file; text from the file as commit 3b941c01de left it):
3385−
3386−```sh
3387− python3 multigram/tools/style_knobs_check.py
3388−fi
3389−```
3390−
3391−**Change it to** (lines starting with `-` go, lines starting with `+` are added, the rest stays):
3392−
3393−```diff
3394− python3 multigram/tools/style_knobs_check.py
3395− fi
3396−
3397−+if [ -f multigram/rebrand/selftest.py ]; then
3398−+ # Rebrand toolkit: generate two identities on a scratch copy, check the output, then --clean back to the commit.
3399−+ echo "== rebrand toolkit: self-test"
3400−+ python3 multigram/rebrand/selftest.py
3401−+fi
3402−+
3403− echo "== style table: format and readability on the current theme sources"
3404− python3 multigram/tools/check_style_table.py
3405−
3406−```
3407−
3408−**Why:** Runs the rebrand self-test in CI. A Forkgram release that moves what the rebrand generator changes (the account type code, app name strings, launcher, splash or notification icons) then fails the sync's compile job, instead of breaking the next rebrand.
3409−
3410−**If the code moved:** Order does not matter. The self-test copies part of the tree to a throwaway folder in the system temp folder and removes it afterwards. It needs `git`; `keytool` is optional.
3411−
3412−
3413−#### 8.4.4 Data
3414−
3415−The knob tables are Java constants in `StyleKnobs.java`. "r" is the bubble radius in dp.
3416−
3417−| Knob (its name in `StyleKnobs.java`) | Values | Stock | What it drives | Hooks |
3418−|---|---|---|---|---|
3419−| `bubble-radius` | 8, 10, 12, 14, 17 dp | 17 | Setting `bubbleRadius` (Chat Settings > Message corners). "Reset to defaults" returns to it. | K1, K16, K18–K20 |
3420−| `chat-list-density` | three lines in 2 of 5 cases, only when the screen's long side is at least 640 dp and the font size is below 19 | two lines | Setting `useThreeLinesLayout` (Chat Settings > chat list view) | K1, K18–K20 |
3421−| (none: follows the live `bubbleRadius`) | outer corner round(r × 0.65), between 4 and 11 dp | 11 dp | Service and date pills | K3, K4 |
3422−| `reaction-chip-shape` | pill, or rounded: round(r × 0.6), between 6 and 10 dp | pill | Reaction chips under messages | K9, K10 |
3423−| `cta-button-radius` | 50, 75 or 100 % of r, between 6 and 12 dp | 8 dp | Default corners of filled buttons | K8, K13, K14 |
3424−| `fab-shape` | circle (3 of 6 cases), or a rounded square of 14, 16 or 18 dp | circle | Chat list floating button, its shadow, and the small button above it (× 36/48) | K5–K7 |
3425−| `settings-cards` | 12, 14, 16, 20 dp | 16 dp | Default corners of settings cards | K11, K12 |
3426−| `wallpaper-phase` | 0 to 7 | continues the old phase | Where the generated wallpaper gradient starts | K2 |
3427−
3428−**Preferences file `rebrand_style_knobs`:** `seed`, `version` (1), `bubble_radius`, `three_lines`, `reaction_pill`, `cta_scale`, `fab_radius`, `section_radius`, `wallpaper_phase`, and `set_bubble_radius` and `set_three_lines` (the values `StyleKnobs` last wrote).
3429−
3430−#### 8.4.5 Regenerate and verify
3431−
3432−Nothing is generated in this unit. To verify, from the root of a checkout:
3433−
3434−```sh
3435−python3 multigram/tools/style_knobs_check.py
3436−python3 multigram/tools/style_knobs_check.py --base origin/forkgram
3437−bash multigram/tools/check.sh
3438−```
3439−
3440−- Both checks print `style-knobs: OK`. With `--base`, every non-blank line added to the 10 hooked files must carry `MultiGram:`.
3441−- A failure lists each problem, then `style-knobs: FAILED, N problem(s)`. For example, with K3's first line put back to stock: `style-knobs: TMessagesProj/src/main/java/org/telegram/ui/Cells/ChatActionCell.java: stock line 'final int corner = dp\\(11\\);' is back (the hook must replace it)`. Patterns are printed as Python strings, so each backslash shows doubled.
3442−- `check.sh` runs the knobs check and the rebrand self-test, and ends with `== all MultiGram checks passed`. On 4780eb095b it took 1 minute 8 seconds on 4 cores.
3443−
3444−**On a device** (the full list is "Device test checklist" in `multigram/style-knobs/README.md`). Knobs work only on installs with a seed: a fresh install, or an old install after "Shuffle my style".
3445−
3446−1. `adb shell run-as <applicationId> cat shared_prefs/rebrand_style_knobs.xml` (debuggable build) shows the keys above.
3447−2. From the first frame, on a phone and on a tablet, Chat Settings shows the install's corner radius and chat list layout.
3448−3. With a small radius, pills have smaller corners but the stock size. Reactions are pills or rounded chips, with particles inside. Sheet buttons have 6 to 12 dp corners. The floating button is a circle or a rounded square, with a matching shadow. Settings cards have the install's corners. The wallpaper gradient starts at the same phase on every start.
3449−4. Shuffle, then Undo: the preview, slider and chat list picker update at once. Move the slider, then Shuffle: your value stays. "Reset to defaults" brings back the install's radius, not 17.
3450−5. Avatars keep Forkgram's own shape setting.
3451−
3452−#### 8.4.6 Gotchas
3453−
3454−- **Apply this unit after 27d32d57b4.** K1's context is S1's line, so K1 does not apply on plain Forkgram.
3455−- **Keep every upstream hook one line**, with the full class name `org.telegram.messenger.multigram.StyleKnobs.` and the exact `// MultiGram: ` marker, at the stock indentation. The check matches these exactly, including leading spaces and stock arguments. The now unused import `ViewOutlineProviderImpl` in `FragmentFloatingButton.java` stays on purpose.
3456−- **Start order.** `StyleKnobs.onApplicationCreate` must come after `RandomStyle.onApplicationCreate` and before anything draws. On tablets, `SharedConfig` is already loaded at that point, so `StyleKnobs` copies the two values into it. Without that copy, tablets show stock values until the next start.
3457−- **K2 must read the old wallpaper,** so it goes before `wallpaper = null`, and in `reloadWallpaper`, not `loadWallpaper`.
3458−- **K3 and K4 go together.** The pill's side padding is corner minus offset. Changing only the corner puts the text against the edge.
3459−- **`ReactionsLayoutInBubble.java` has two identical lines `float rad = height / 2f;`.** Hook both (K9, K10).
3460−- **`ButtonWithCounterView` needs both K13 and K14.** Subclasses that draw a fixed radius must opt out, as `GradientButtonWithCounterView` does (K8). Today: `FoundStickerPackButton` calls `setRound()` and is fine; `UpdateReactionsButton` draws nothing of its own; `StickerCutOutBtn` keeps its own 8 dp selector while its loading shimmer follows the knob (not handled; check it on a device). After an update, run `git grep -n 'extends ButtonWithCounterView'`.
3461−- **`UniversalRecyclerView` repeats the `setSections` overloads.** Hooking only `RecyclerListView` misses the main Settings screen.
3462−- **K17 must stay inside `ListAdapter`**, because the three view types are private to it. If upstream adds its own `case` for one of them, call `bindStyleKnobRow` from there instead: duplicate case labels do not compile. `refreshStyleKnobRows` (K15) must be public.
3463−- **K18 to K20 must come right before `show(pair);`.** The theme switch reloads the wallpaper at once, so a listener would be too late.
3464−- **Never rename a knob name** (the `KNOB_*` constants): that re-rolls the knob for every new seed. Keep the tables inside the check's ranges.
3465−- **Nothing changes on an existing install until it presses Shuffle.** Test on a fresh install.
3466−- **Never add Forkgram's avatar settings** (`avatarCorners`, `squareAvatars`) to the knobs. The check fails on them.
3467−- **The rebrand self-test in `check.sh`** takes about a third of the script's time (about 26 seconds on 4 cores; `make_style_table.py --check` takes the most, about 30 seconds). It writes a temporary folder in the system temp folder (and removes it), and skips the signing checks without `keytool`.
3468−
3469−## 9. Plain Telegram (DrKLO)
3470−
3471−This section says what happens if you put the stack on Telegram's own code (DrKLO/Telegram) instead of Forkgram. It was tested by applying the six exported patches (5.7) with `git am -3`, one at a time, to DrKLO dc780e81e ("update to 12.10.5 (7105)"), the Telegram base of Forkgram 12.10.6.
3472−
3473−### 9.1 What applies
3474−
3475−| Commit | Applies? | Conflicts | Forkgram-only dependencies |
3476−|---|---|---|---|
3477−| d457ac74ae rebrand | No: `git am -3` stops with three conflicts. The new files and hook R1 go in without trouble. | `DialogsActivity.java` (hook R2: Forkgram's custom title code does not exist); `ForkSettingsActivity.java` (the file does not exist); `TMessagesProj_App/build.gradle` (DrKLO's file ends with `apply plugin: 'com.google.gms.google-services'`: keep both lines) | `forkCustomTitle` and `ForkSettingsActivity`; the generator's icon names and file types |
3478−| 5b53859682 palette | Yes, cleanly. `palette_fix_check.py` passes. | none | `ThemeInfo.isMonet()` in `PaletteFix.java`: a compile error |
3479−| cddd62df98 style table | Yes, cleanly. The table checks pass, and `make_style_table.py --check` finds the table identical. | none | none: `ThemeColors.java` and the five `.attheme` files are the same in both |
3480−| 27d32d57b4 random style | Yes, cleanly. Every hook lands where the check expects it. | none | `ThemeInfo.isMonet()` in `RandomStyle.java` (a compile error); `random_style_check.py` crashes because `forkgram/SettingsBackup.kt` is missing |
3481−| 3b941c01de knobs | Yes, cleanly. `style_knobs_check.py` passes. | none | none |
3482−| 4780eb095b CI | Yes, cleanly. | none | Indirect: `check.sh` stops at the random style check, and the rebrand self-test fails on DrKLO's icons (9.2, item 2). |
3483−
3484−So the text applies almost completely, but the result does not build or pass `check.sh` as it is.
3485−
3486−### 9.2 What would have to change
3487−
3488−1. **Rebrand hooks.** The rule of 5.2 does not fit these conflicts, because the MultiGram side holds Forkgram code that Telegram does not have. 9.4 gives the commands.
3489− - Drop hooks R3 and R4: `ForkSettingsActivity.java` does not exist. `git am -3` leaves Forkgram's whole file in the tree anyway (`deleted in HEAD and modified in Add the rebrand toolkit`). Remove it; it does not compile on DrKLO.
3490− - Drop hook R2 as it is: DrKLO has no `forkCustomTitle`. The HEAD side of the conflict is empty, and the other side is R2 plus Forkgram's own title code. Keep DrKLO's side. Putting R2 back would even replace Telegram's logo title with the text "Fork Client" in a stock build. DrKLO's chat list title draws the picture `telegram_logo_2` over the text of `R.string.AppName` (`DialogsActivity.java` line 3517 in dc780e81e). Renaming `AppName` therefore does not change what is shown. A rebranded build would need a new hook after `actionBar.setTitle(ssb, statusDrawable);` (line 3519) that shows `Rebrand.defaultTitle(getString(R.string.AppName))` as plain text when `Rebrand.isActive()`. `Rebrand.defaultTitle` reads `AppName` through Android, as 8.1.6 requires. That hook has not been written or tested.
3491− - In `TMessagesProj_App/build.gradle`, keep DrKLO's `apply plugin` line and add the `apply from:` line (R5) after it.
3492−2. **Rebrand generator** (`multigram/rebrand/generate_rebrand.py` and `selftest.py`). DrKLO has no `icon_01_*` icons and no `splash_fork_320`. Its default icon uses `icon_foreground_sa` (PNG files) and `icon_background_sa` (a `<shape>` XML file). Its `ic_launcher_dr` and `notification` are `.webp` files, where Forkgram has `.png`. The self-test fails with `error: unexpected drawable-hdpi/ic_launcher_dr.webp: update ICON_TARGETS in multigram/rebrand/generate_rebrand.py`, prints more `FAIL` lines, and then stops with a Python traceback (`KeyError: 'REBRAND_KEYSTORE'`) instead of a `FAILED` line.
3493− - Editing `ICON_TARGETS` is not enough. `write_overlay()` writes only PNG files, adaptive icon XML and splash XML, and stops with `unexpected ...: update ICON_TARGETS` on anything else, such as the `.webp` icons or `icon_background_sa.xml`. Either teach `write_overlay()` those formats, or leave those three icons out of `ICON_TARGETS`; a rebranded build then keeps Telegram's account icon, notification icon and icon background.
3494− - `selftest.py` must change with it: it looks for `notification.png` (line 247) and requires the "same file type and pixel size as upstream".
3495− - DrKLO's `auth.xml` and `sync_contacts.xml` say `org.telegram.messenger`, so change `STOCK_XML_ACCOUNT_TYPE` too.
3496− - A test with only `ic_launcher`, `ic_launcher_round`, `icon_foreground_sa` and `tg_splash_320` in `ICON_TARGETS`, and the new account type, ended with `FAILED: 4 failure(s)`: `seed A: no warnings` (the google-services warning of item 6) and three times `0 notification icons are white on transparent`.
3497−3. **Monet.** Replace `theme.isMonet()` at `PaletteFix.java` lines 165 and 225 and `RandomStyle.java` lines 449 and 624 with `false`. DrKLO has no Monet themes.
3498−4. **`random_style_check.py`.** Make the `SettingsBackup.kt` part optional. DrKLO has no settings export. The Android backup part applies as it is.
3499−5. **API id and hash.** DrKLO sets `APP_ID = 4` and Telegram's `APP_HASH` in `BuildVars.java`, and ignores the `APP_ID` and `APP_HASH` Gradle properties that the APK job writes. Forkgram copies them from `BuildConfig` (`BuildVars.java` lines 66 and 67). On DrKLO, edit `BuildVars.java` or port Forkgram's way.
3500−6. **Application id and Firebase.** DrKLO's id is `org.telegram.messenger`, the official app's id. A rebrand, or at least a new id, is required. DrKLO applies the google-services plugin in two modules: `TMessagesProj/build.gradle` (line 253) and `TMessagesProj_App/build.gradle` (line 208). Both `google-services.json` files list only `org.telegram.messenger`, `.beta` and `.web`. For a new id, put a `google-services.json` from your own Firebase project that lists `<id>`, `<id>.beta` and `<id>.web` in `TMessagesProj_App/`, or delete the `apply plugin` line of `TMessagesProj_App/build.gradle`. While that line is there, the rebrand generator warns about it (item 2).
3501−
3502−### 9.3 Build differences
3503−
3504−- **`-x :TMessagesProj:buildNativeDeps`** in `build.yml` must go: the task exists only in Forkgram (`TMessagesProj/build.gradle` line 282), and Gradle rejects excluding a task that does not exist. DrKLO links prebuilt native libraries from `TMessagesProj/jni/prebuild/`.
3505−- **Modules.** DrKLO's `settings.gradle` also includes `:TMessagesProj_AppHuawei`, `:TMessagesProj_AppHockeyApp`, `:TMessagesProj_AppStandalone` and `:TMessagesProj_AppTests`, and its root `build.gradle` adds Huawei and Firebase build plugins.
3506−- **Flavors.** The flavor `afat`, the tasks `compileAfatReleaseJavaWithJavac` and `assembleAfatRelease`, and the APK output folder exist in both.
3507−- **Signing.** DrKLO signs every build type with `TMessagesProj/config/release.keystore`, a public key whose passwords are in its `gradle.properties` (`RELEASE_STORE_PASSWORD=android`, `RELEASE_KEY_ALIAS=androidkey`, `RELEASE_KEY_PASSWORD=android`). It ignores Forkgram's `RELEASE_KEYSTORE_FILE`, but reads the other three properties. So with the `MULTIGRAM_KEYSTORE_*` secrets set (10.4), the APK job passes your passwords for DrKLO's keystore, which they do not open. Without the secrets, the APK is signed with DrKLO's public key. Port Forkgram's `signingConfigs` in `TMessagesProj_App/build.gradle`, which read `RELEASE_KEYSTORE_FILE`.
3508−- The APK job's extra tools (meson, nasm, rust targets) serve Forkgram's native build and are probably not needed for DrKLO. This was not tested.
3509−
3510−### 9.4 How to try it
3511−
3512−```sh
3513−git clone --recursive --shallow-submodules https://github.com/DrKLO/Telegram.git
3514−cd Telegram
3515−git fetch --depth 1 https://github.com/forkgram/TelegramAndroid.git tag 12.10.6.0
3516−git switch -c multigram dc780e81ed1261c369c27870e8e0999a1eb0b600
3517−git am -3 /path/to/multigram-patches/0001-*.patch
3518−```
3519−
3520−- The start point of `git switch` is the commit this section was tested on: the `Telegram-Base` line of the snapshot the patches were made on (`git log -1 --format=%B origin/forkgram`). DrKLO's `master` moves on; on any other commit, expect other conflicts and line numbers than in 9.1 and 9.2.
3521−- The `git fetch` of Forkgram's release gives `git am -3` the old file versions it needs. Use the tag that `APPLY.txt` names (the Forkgram version plus `.0`).
3522−
3523−Patch 1 stops with three conflicts (9.1). Settle them as 9.2 item 1 says:
3524−
3525−```sh
3526−git rm -q TMessagesProj/src/main/java/org/telegram/ui/ForkSettingsActivity.java
3527−git checkout --ours TMessagesProj/src/main/java/org/telegram/ui/DialogsActivity.java
3528−sed -i -e '/^<<<<<<< /d' -e '/^=======$/d' -e '/^>>>>>>> /d' TMessagesProj_App/build.gradle
3529−git add TMessagesProj/src/main/java/org/telegram/ui/DialogsActivity.java TMessagesProj_App/build.gradle
3530−git am --continue
3531−git am -3 /path/to/multigram-patches/000[2-6]-*.patch
3532−```
3533−
3534−- `git rm` drops R3 and R4 with Forkgram's file. `git checkout --ours` keeps DrKLO's `DialogsActivity.java`, without R2. The `sed` deletes the three marker lines, the only conflict in `build.gradle`: DrKLO's `apply plugin` line stays first and R5 follows.
3535−- Patches 2 to 6 then apply without conflicts.
3536−
3537−Then make changes 2 to 6 of 9.2 and commit them; without change 3 the Java code does not compile. To check the result:
3538−
3539−```sh
3540−python3 multigram/tools/palette_fix_check.py --base dc780e81e
3541−python3 multigram/tools/style_knobs_check.py --base dc780e81e
3542−python3 multigram/tools/random_style_check.py --base dc780e81e
3543−git grep -n -e 'multigram\.Rebrand\.' -e 'multigram/rebrand/rebrand.gradle' -- TMessagesProj TMessagesProj_App
3544−```
3545−
3546−- The first two print `OK` right after the patches. `random_style_check.py` stops with `FileNotFoundError` on `SettingsBackup.kt` until change 4 is done; then it prints `OK` too.
3547−- The `git grep` prints 2 lines here (R1 and R5), not the 5 of 5.4.
3548−- `bash multigram/tools/check.sh` stops at the random style check until change 4 is done, and at the rebrand self-test until change 2 is done and the google-services `apply plugin` line of `TMessagesProj_App/build.gradle` is gone (change 6). While that line is there, the self-test fails `seed A: no warnings`.
3549−
3550−## 10. Verifying
3551−
3552−### 10.1 The compile check on Actions
3553−
3554−In the browser: **Actions > Build MultiGram > Run workflow**. Leave "Use workflow from" on `main`, because the workflow file lives there. Type the branch to build in **ref**: `multigram-next` for a fix, `multigram` otherwise. Tick **APK** only for a release build (10.4).
3555−
3556−From a terminal (not run while writing this guide):
3557−
3558−```sh
3559−gh workflow run build.yml -R iko-soy/Multigram --ref main -f ref=multigram-next
3560−gh run list -R iko-soy/Multigram --workflow build.yml -L 5
3561−gh run watch <run-id> -R iko-soy/Multigram
3562−gh run view <run-id> -R iko-soy/Multigram --log-failed
3563−```
3564−
3565−The job **Compile Java and Kotlin**:
3566−
3567−1. checks out the ref, and fetches the two submodules the Java code needs (`TMessagesProj/lib/jlatexmath` and `TMessagesProj_Modules/media`);
3568−2. sets up Java 17 (Temurin) and Gradle;
3569−3. compiles the Java and Kotlin code of both modules, without the native code (step **Compile**);
3570−4. runs `bash multigram/tools/check.sh` if the file exists (step **Run MultiGram tool checks**).
3571−
3572−Past runs took about 8 to 9 minutes: about 4.5 minutes to compile and about 3 minutes for the checks (from the Actions history, not measured again for this guide). The sync's compile job calls this same workflow.
3573−
3574−### 10.2 check.sh
3575−
3576−`bash multigram/tools/check.sh` needs only bash and python3. It took 1 minute 8 seconds on 4 cores locally, and 2 to 3 minutes on CI. It stops at the first failure. Each part prints a header line that starts with `==`:
3577−
3578−| Header | What it checks | Passes with | A failure usually means |
3579−|---|---|---|---|
3580−| `== palette fix: ...` | `palette_fix_check.py`: colour values, `PaletteFix.java` tables, the 12 palette hook lines | `palette-fix: OK` | A palette hook is missing or doubled, or upstream changed a colour that overlay A replaces. See 8.2.5. |
3581−| `== random style: ...` | `random_style_check.py`: the 15 hooks and their places, privacy, strings, backup policy | `random-style: OK` | A random style hook is missing or in the wrong place. A Python `FileNotFoundError` means Forkgram moved `forkgram/SettingsBackup.kt`. A line `<file>: <X> not found after <Y>` can also mean that upstream changed a line the check uses as a landmark (`check_placement()` in `random_style_check.py`): if the hook still sits where its "If the code moved" says, update that pattern. See 8.3.5. |
3582−| `== style knobs: ...` | `style_knobs_check.py`: the knob hooks, stock lines gone, safe ranges | `style-knobs: OK` | A knob hook is missing, a stock line came back, or upstream reformatted a hooked line. Placement failures can also come from a changed landmark (8.4.2 lists them in `style_knobs_check.py`). See 8.4.5. |
3583−| `== rebrand toolkit: self-test` | `selftest.py`: two generated identities and `--clean` | `PASSED: 0 failure(s)` | Forkgram renamed or re-encoded an icon, string or account type literal the generator targets. The error names it, for example `update ICON_TARGETS`. See 8.1.5. |
3584−| `== style table: format and readability ...` | `check_style_table.py`: all 20,480 styles on this tree's theme sources | `style table check: PASS (...)` | Upstream colours changed so much that some styles are unreadable. Make a new table (8.3.5). |
3585−| `== style table: does this tree regenerate the same asset?` | `make_style_table.py --check` | `identical to ...` | Status 3 prints a `WARNING` and passes: make a new table when convenient. Status 4 fails: fix the palette first. Any other status fails with `FAIL: make_style_table.py exited with status N`. |
3586−
3587−The last line on success is `== all MultiGram checks passed`.
3588−
3589−A fix to a check script goes into the commit that added the script (6.3): `random_style_check.py` into "Give each install its own random style", `style_knobs_check.py` into "Randomise shapes and chat list layout per install", `palette_fix_check.py` into "Fix palette contrast for generated accents".
3590−
3591−### 10.3 Hooks no script checks
3592−
3593−- The four rebrand Java hooks: run the `git grep` of 8.1.5 and expect 5 lines (with R5).
3594−- Seven of the nine MultiGram lines in `ChatActivityEnterView.java` (P14, and all of P15 except the `int glyph = ...` and `drawableInverse` lines). Read them with `git grep -n MultiGram -- TMessagesProj/src/main/java/org/telegram/ui/Components/ChatActivityEnterView.java`. It must print 9 lines.
3595−
3596−### 10.4 Release APK
3597−
3598−Run **Build MultiGram** with **APK** ticked (or add `-f apk=true` to the `gh` command). The job **Release APK** starts after the compile job and takes about 70 minutes, most of it for the native code; the whole run takes about 80 minutes ([run 36306473313](https://github.com/iko-soy/Multigram/actions/runs/36306473313), 2026-09-27). Its time limit is 300 minutes.
3599−
3600−Secrets, in **Settings > Secrets and variables > Actions**:
3601−
3602−| Secret | Needed? | What it is |
3603−|---|---|---|
3604−| `MULTIGRAM_APP_ID`, `MULTIGRAM_APP_HASH` | Yes | Your API id and hash from https://my.telegram.org. Without them the APK cannot log in. |
3605−| `MULTIGRAM_KEYSTORE_BASE64` | For APKs you give to others | Your release keystore, as `base64 -w0 release.keystore`. |
3606−| `MULTIGRAM_KEYSTORE_PASSWORD`, `MULTIGRAM_KEY_ALIAS` | With the keystore | The keystore password and the key's alias. |
3607−| `MULTIGRAM_KEY_PASSWORD` | Only if it differs from the keystore password | The key's password. |
3608−
3609−Without the keystore secrets the job signs the APK with Forkgram's public test key (`TMessagesProj/config/test.keystore`) and prints a warning. That is fine for testing, but anyone could sign an "update" for such an APK. Keep one key for every build you give out, or phones cannot update. The rebrand toolkit can make a key (see "Signing and updates" in `multigram/rebrand/README.md`).
3610−
3611−The job adds 8 GB of swap and smaller Java memory settings, because runners for private repositories have about 8 GB of memory. It builds with `./gradlew :TMessagesProj_App:assembleAfatRelease`. The APK is attached to the run as the artifact `multigram-apk`. To download it from a terminal: `gh run download <run-id> -R iko-soy/Multigram -n multigram-apk`.
3612−
3613−### 10.5 Device checklist
3614−
3615−After each update:
3616−
3617−1. **Update over the previous MultiGram build** (same key): it installs as an update, the style does not change, and "Shuffle my style" still works.
3618−2. **Fresh install, light system theme:** the first frame already shows the day style, with no flash of the Blue theme. **Dark system theme:** the night style. Bubble corners and the chat list layout are right from the first frame.
3619−3. **Chat Settings:** Shuffle, then Undo. The preview, radius slider and chat list picker update at once. "Reset to defaults" returns to the install's style and radius. The custom theme tile applies the current generated style.
3620−4. **Sharing the generated accent** shows the "stays on this device" message and uploads nothing. "Create new theme" shows the message instead of the dialog.
3621−5. **Shapes:** service and date pills, reaction chips, sheet buttons, the chat list floating button with its shadow, settings cards, and the wallpaper gradient's start phase.
3622−6. **Readability:** grey texts, times in bubbles, the message field hint, and the send and voice glyphs on the accent, in day and night.
3623−7. **Monet:** a selected Monet theme survives a restart. Shuffle replaces it with a generated style (by design). "Reset to defaults" leaves it alone.
3624−8. **Rebranded build only:** app name, launcher, splash and notification icons, a single icon in the icon picker, the chat list title, and the account in **Settings > Accounts** with working contact sync.
3625−9. **Tablet or unfolded foldable:** knob values from the first frame.
3626−
3627−The full lists are in `multigram/random-style/README.md` (15 items) and `multigram/style-knobs/README.md` (10 items).
3628−
3629−## 11. Keeping this guide current
3630−
3631−### 11.1 Check and refresh the hooks
3632−
3633−**After every upstream update**, even one that replayed with no conflict, the "Find this" texts and the line numbers in section 8 can go stale. No check script looks at them, but this does. Run it in the folder that holds `PATCH.md` (the `docs` branch, 11.2), in a clone that has `origin/forkgram`:
3634−
3635−```sh
3636−python3 - origin/forkgram <<'EOF'
3637−import re, subprocess, sys
3638−rev = sys.argv[1]
3639−if subprocess.run(['git', 'rev-parse', '--verify', '-q', rev + '^{commit}'], stdout=subprocess.DEVNULL).returncode:
3640− sys.exit('unknown revision: ' + rev)
3641−doc = open('PATCH.md', encoding='utf-8').read()
3642−for part in re.split(r'^##### ', doc, flags=re.M)[1:]:
3643− hook = part.split('.')[0]
3644− path = re.search(r'\*\*File:\*\* `([^`]+)`', part).group(1)
3645− if 'multigram' in path.split('/'):
3646− continue # MultiGram's own files (K18 to K24)
3647− show = subprocess.run(['git', 'show', rev + ':' + path], capture_output=True, text=True)
3648− if show.returncode:
3649− print(hook, path, 'is missing')
3650− continue
3651− src = show.stdout
3652− for kind, block in re.findall(r'\*\*(Find this|Then find these)[^\n]*\n\n```[a-z]*\n(.*?)```', part, re.S):
3653− for text in (block.splitlines() if kind.startswith('Then') else [block]):
3654− if src.count(text) != 1:
3655− print(hook, 'found', src.count(text), 'times:', ' '.join(text.split())[:60])
3656− elif kind == 'Find this':
3657− line = src[:src.index(text)].count('\n') + 1
3658− said = re.search(r'starts at line (\d+)', part)
3659− if said and int(said.group(1)) != line:
3660− print(hook, 'now starts at line', line, 'not', said.group(1))
3661−EOF
3662−```
3663−
3664−It prints one line for each hook text that no longer occurs exactly once, and one for each hook whose text now starts at another line than its "Where" says. On `forkgram` 4543767655 it prints a single line, because K1's text comes from the stack (1.5):
3665−
3666−```text
3667−K1 found 0 times: org.telegram.messenger.multigram.RandomStyle.onApplicationCr
3668−```
3669−
3670−Refresh every other hook it names with the commands at the end of this section.
3671−
3672−**After the stack changes** (a fixup after an update, say), also compare these lists with section 3 and section 8. Run them from any checkout:
3673−
3674−```sh
3675−git fetch origin
3676−git diff --stat --diff-filter=M origin/forkgram origin/multigram -- . ':!multigram' ':!TMessagesProj/src/main/java/org/telegram/messenger/multigram'
3677−git diff --name-only --diff-filter=A origin/forkgram origin/multigram
3678−git diff -U0 --diff-filter=M origin/forkgram origin/multigram -- . ':!multigram' ':!TMessagesProj/src/main/java/org/telegram/messenger/multigram'
3679−```
3680−
3681−1. The first command lists the upstream files the stack touches. On 4780eb095b it ends with `30 files changed, 114 insertions(+), 73 deletions(-)`: 78 and 37 lines in Java and Gradle files, plus 36 and 36 in the `.attheme` files.
3682−2. The second lists the new files: 37 on 4780eb095b.
3683−3. The third shows every hook line with no context.
3684−
3685−The code blocks of each hook come straight from git:
3686−
3687−- "Change it to" is `git diff -U3 <commit>^ <commit> -- <file>` for the commit that adds the hook. The value lists P1 to P6 use `-U0`, which shows only the changed lines.
3688−- "Find this" is the matching text in `git show origin/forkgram:<file>`, and the line number in "Where" is its line there. For hooks in MultiGram's own files (K18 to K24), and for K1, which sits on the line after S1, it is the text in the file as the previous stack commit left it.
3689−
3690−When a hook changes, refresh its blocks with these commands. Do not type code by hand.
3691−
3692−### 11.2 Update the stamp
3693−
3694−Change the table in 1.1:
3695−
3696−- the `multigram` commit: `git rev-parse --short=10 origin/multigram`;
3697−- the Forkgram version and commit, and the Telegram base: `git log -1 --format=%B origin/forkgram`;
3698−- the `forkgram` and `main` commits;
3699−- the date.
3700−
3701−Then update the data that belongs to one version. Most of it is marked "on 4780eb095b", "in Forkgram 12.10.6" or "today"; `grep -n -e '4780eb095b' -e '12\.10\.6' -e '[Tt]oday' PATCH.md` finds those places. In short:
3702−
3703−- the counts in 3.1, and the stat line and file count in 11.1;
3704−- the "starts at line" of each hook (the script in 11.1 names the stale ones);
3705−- line numbers in MultiGram's own files: 6.2 and 9.2 (`PaletteFix.java` 165, 225 and 316; `RandomStyle.java` 449 and 624), and `selftest.py` line 270 (8.1.4, 8.1.6);
3706−- the problem counts in 7.4 (59, 27 and 35), and the line counts of the `git grep` commands in 5.4, 8.1.5, P13 and 8.2.6;
3707−- 8.1.4 (string lines, 54 icon files), the old-value counts in P3 and P6, and the values and contrasts in 8.2.4;
3708−- the expected outputs in 8.1.5, 8.2.5 and 8.3.5, and the CRC in 8.3.4;
3709−- the timings in 4.4, 8.1.5, 8.3.5, 8.4.5, 8.4.6, 10.1 and 10.2;
3710−- the DrKLO commit and line numbers in section 9.
3711−
3712−This guide lives on the branch `docs`. If you keep a separate worktree for that branch, run these commands there instead of switching:
3713−
3714−```sh
3715−git switch docs
3716−git add PATCH.md
3717−git commit -m "Update PATCH.md for Forkgram <version>"
3718−git push origin docs
3719−```
README.mdnew file+140
@@ -0,0 +1,140 @@
1+# MultiGram
2+
3+MultiGram is Forkgram (https://github.com/forkgram/TelegramAndroid) plus a small stack of MultiGram changes.
4+
5+| Branch | What it holds |
6+|---|---|
7+| `forkgram` | Snapshots of Forkgram's `dev` branch, one commit per Forkgram release (the `.github` folder is left out). |
8+| `multigram` | The app: the latest `forkgram` snapshot plus the MultiGram commits on top. Build this branch. |
9+| `main` | This README, the automation that keeps `multigram` in step with Forkgram, and helper scripts. |
10+
11+The sync also uses `multigram-next` (the next `multigram`, while it is being checked) and tags
12+the `multigram` it replaces as `multigram-before-<version>`.
13+
14+[PATCH.md](https://github.com/iko-soy/Multigram/blob/docs/PATCH.md) on the `docs` branch says what
15+to do after a Telegram update when the sync needs a hand, and lists every MultiGram change with
16+the exact code, so the stack can be re-applied by hand.
17+
18+## Build
19+
20+**Actions > Build MultiGram > Run workflow**, ref `multigram`. It compiles the Java and Kotlin
21+code (a few minutes). Tick **APK** to also build a release APK; it compiles the native code
22+too, so it takes much longer, and the APK is attached to the run as `multigram-apk`.
23+
24+## Releases
25+
26+**Actions > Release MultiGram > Run workflow** builds the release APK of `multigram` and publishes
27+it on the Releases page (about 80 minutes; tick **dry_run** to see only the version and notes).
28+Versions follow Forkgram's scheme: `12.10.6.1` is the first MultiGram release on Forkgram 12.10.6,
29+`12.10.6.2` the second, and after a Forkgram update the count starts again at 1 (at most 9 per
30+Forkgram version). The tag is the version. It needs the API id and hash secrets below. Without the
31+signing secrets the APK is signed with Forkgram's public test key and published as a pre-release
32+that says so. Details: section 10.6 of
33+[PATCH.md](https://github.com/iko-soy/Multigram/blob/docs/PATCH.md).
34+
35+## Secrets
36+
37+Set these in **Settings > Secrets and variables > Actions**:
38+
39+- `MULTIGRAM_APP_ID` and `MULTIGRAM_APP_HASH`: your own API id and hash from
40+ https://my.telegram.org (API development tools). Without them the APK cannot log in.
41+- Signing (optional): `MULTIGRAM_KEYSTORE_BASE64` (your release keystore, `base64 -w0 release.keystore`),
42+ `MULTIGRAM_KEYSTORE_PASSWORD`, `MULTIGRAM_KEY_ALIAS` and `MULTIGRAM_KEY_PASSWORD` (if it differs
43+ from the store password). Without them the APK is signed with Forkgram's public test key
44+ (`TMessagesProj/config/test.keystore`), which is fine for testing but lets anyone sign an
45+ "update" for it. Keep the same key for every build you give out, or phones can't update. The
46+ rebrand toolkit can make a key for you (see "Signing and updates" in `multigram/rebrand/README.md`).
47+
48+## How the sync works
49+
50+The **Sync with Forkgram** workflow runs every 6 hours (and by hand):
51+
52+1. If Forkgram's `dev` has a new tip (Forkgram rebases `dev` onto every Telegram release, so each
53+ new tip is a release candidate), it adds a snapshot commit to `forkgram`: that tree without
54+ `.github`, with `Forkgram-Commit`, `Forkgram-Version` and `Telegram-Base` lines in the message.
55+2. It replays the MultiGram commits (`forkgram..multigram`) onto the snapshot as `multigram-next`
56+ (`git rebase --onto`).
57+3. It compiles `multigram-next` with the Build MultiGram workflow.
58+4. If that passes, it tags the old `multigram` as `multigram-before-<version>`, moves `multigram`
59+ to `multigram-next`, deletes `multigram-next` and closes the sync issue.
60+5. On a conflict or a failed compile it opens an issue labelled `forkgram-sync` (or comments on the
61+ open one) with the details; `multigram` stays as it was.
62+
63+It never replaces a `multigram-next` that has commits it did not make (your fix, say): it then
64+changes nothing, not even `forkgram`, and says so on the issue until you run **use_next** or
65+delete `multigram-next`. It records the `multigram-next` it built last in the ref
66+`refs/multigram-sync/next`. Keep `multigram` linear (rebase, do not merge): the replay flattens
67+merges, so a merge commit with changes of its own stops the sync.
68+
69+Run it by hand with **force** to rebuild `multigram-next` without a new release (to test the
70+pipeline, or to retry), or with **use_next** to compile and promote a `multigram-next` you fixed
71+yourself. **use_next** checks that `multigram-next` still has every commit on `multigram` (a
72+commit pushed to `multigram` after you started the fix would otherwise be lost); tick
73+**allow_drop** as well if it leaves some out on purpose.
74+
75+The work is done by `scripts/sync-forkgram.sh` (see `--help`). It also runs locally without
76+pushing anything: from a checkout of `main`, `bash scripts/sync-forkgram.sh --remote origin`
77+leaves the results in your local `forkgram` and `multigram-next` branches.
78+`bash scripts/selftest-sync.sh` tests it against fake repositories.
79+
80+rerere (git's memory of conflict fixes) only knows the fixes recorded in the clone it runs in.
81+It helps when you redo a rebase or run the script in your own clone; the scheduled sync starts
82+from a fresh clone every time, so it reports a conflict again until `multigram` carries the fix.
83+
84+## When the sync issue opens
85+
86+The issue has the exact commands; in short:
87+
88+**Conflict.** Replay the stack yourself, fix the files, push the result to `multigram-next`:
89+
90+```sh
91+git fetch origin
92+git switch -C multigram-next origin/multigram
93+git config rerere.enabled true # reuses your fixes if you redo this rebase here
94+git rebase origin/forkgram
95+# fix the files git names, then: git add <files> && git rebase --continue (repeat)
96+git push --force origin multigram-next
97+```
98+
99+**Compile failure.** Fix the build on top of `multigram-next` and fold the fix into the commit it
100+belongs to:
101+
102+```sh
103+git fetch origin
104+git switch -C multigram-next origin/multigram-next
105+# edit, then, once per MultiGram commit your fix touches (name only that commit's files):
106+git commit --fixup=<the MultiGram commit it belongs to> -- <its files>
107+GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash origin/forkgram
108+git push --force origin multigram-next
109+```
110+
111+Then **Actions > Sync with Forkgram > Run workflow** with **use_next** ticked: it compiles
112+`multigram-next` and, if that passes, moves `multigram` to it and closes the issue. If the
113+compile failed because of a runner or network hiccup, **Re-run failed jobs** on the run page
114+is enough. Until then the sync leaves your `multigram-next` alone; if you give up on it, delete
115+it (`git push origin --delete multigram-next`) and the next sync starts over from `multigram`.
116+
117+## Patches
118+
119+`bash scripts/export-patches.sh --base origin/forkgram --head origin/multigram` writes the stack
120+as numbered patches to `multigram-patches/`, with `APPLY.txt` saying how to apply them onto a
121+plain Forkgram checkout (`git am -3`).
122+
123+## What is on the multigram branch
124+
125+- `multigram/rebrand/`: the rebrand toolkit that gives a build its own name, applicationId,
126+ launcher icon and signing key (instructions in its `README.md`).
127+- `multigram/hide-search-bar/`: the "Hide chat list search" option in Fork Client Settings >
128+ Chat list view (off by default), which removes the search bar and search icon from the chat list.
129+- `multigram/`: the other MultiGram tools and notes, such as the per-install random style and
130+ its palette check; the app code is in
131+ `TMessagesProj/src/main/java/org/telegram/messenger/multigram/`.
132+
133+## Repository settings
134+
135+- `main` must be the default branch: scheduled workflows only run from it.
136+- The workflows ask for the token permissions they need, so the default workflow permissions
137+ can stay read-only.
138+- Issues must be enabled: the sync reports on an issue labelled `forkgram-sync`.
139+- If you protect `forkgram`, `multigram`, `multigram-next` or `multigram-before-*` tags with
140+ branch protection or rulesets, let GitHub Actions push and force-push them.
scripts/export-patches.shnew file+86
@@ -0,0 +1,86 @@
1+#!/usr/bin/env bash
2+# Write the MultiGram stack as numbered patches (git format-patch forkgram..multigram) into
3+# a folder, with APPLY.txt saying how to apply them onto a plain Forkgram checkout.
4+#
5+# scripts/export-patches.sh [--base REF] [--head REF] [--force] [FOLDER]
6+#
7+# --base REF the snapshot branch (default: forkgram)
8+# --head REF the app branch (default: multigram)
9+# --force replace the patches of an earlier export in FOLDER
10+# FOLDER where to write them (default: multigram-patches)
11+#
12+# For branches you have not checked out locally, pass --base origin/forkgram
13+# --head origin/multigram after a git fetch.
14+set -euo pipefail
15+
16+die() { printf 'export-patches: %s\n' "$*" >&2; exit 1; }
17+
18+base=forkgram head=multigram force=no dir=multigram-patches
19+while [ $# -gt 0 ]; do
20+ case $1 in
21+ --base) base=${2:?--base needs a ref}; shift 2 ;;
22+ --head) head=${2:?--head needs a ref}; shift 2 ;;
23+ --force) force=yes; shift ;;
24+ -h | --help) sed -n '2,/^set -euo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
25+ -*) die "unknown option: $1 (see --help)" ;;
26+ *) dir=$1; shift ;;
27+ esac
28+done
29+
30+git rev-parse --git-dir >/dev/null 2>&1 || die "run this inside a clone of the MultiGram repository"
31+base_c=$(git rev-parse -q --verify "$base^{commit}") || die "no such ref: $base"
32+head_c=$(git rev-parse -q --verify "$head^{commit}") || die "no such ref: $head"
33+count=$(git rev-list --count "$base_c..$head_c")
34+[ "$count" -gt 0 ] || die "$head has no commits on top of $base"
35+[ -z "$(git rev-list --merges "$base_c..$head_c")" ] ||
36+ die "$head has merge commits on top of $base; git format-patch cannot export them"
37+
38+# The snapshot the stack sits on, and the Forkgram commit it was made from.
39+snap=$(git merge-base "$base_c" "$head_c") || die "$head shares no history with $base"
40+msg=$(git log -1 --format=%B "$snap")
41+fg_commit=$(printf '%s\n' "$msg" | sed -n 's/^Forkgram-Commit:[[:space:]]*\([0-9a-f]\{40\}\).*/\1/p' | tail -n 1)
42+fg_version=$(printf '%s\n' "$msg" | sed -n 's/^Forkgram-Version:[[:space:]]*\([0-9A-Za-z._+-]*\).*/\1/p' | tail -n 1)
43+[ -n "$fg_commit" ] || die "$(git rev-parse --short "$snap") (where $head meets $base) is not a Forkgram snapshot"
44+[ -n "$fg_version" ] || fg_version=unknown
45+if [ "$snap" != "$base_c" ]; then
46+ printf 'export-patches: note: %s is based on an older snapshot than %s; the patches are for Forkgram %s\n' \
47+ "$head" "$base" "$fg_version" >&2
48+fi
49+
50+mkdir -p "$dir"
51+if [ -n "$(ls -A "$dir")" ]; then
52+ [ "$force" = yes ] || die "$dir is not empty (use --force to replace an earlier export)"
53+ find "$dir" -mindepth 1 -maxdepth 1 \( -name '[0-9][0-9][0-9][0-9]-*.patch' -o -name APPLY.txt \) -exec rm -f {} +
54+ [ -z "$(ls -A "$dir")" ] || die "$dir holds files this script did not write; pick another folder"
55+fi
56+
57+git format-patch --quiet --full-index --binary -o "$dir" "$base_c..$head_c"
58+abs=$(cd "$dir" && pwd)
59+
60+cat >"$dir/APPLY.txt" <<EOF
61+MultiGram patches
62+=================
63+
64+$count patch(es): the MultiGram commits of $head ($(git rev-parse --short "$head_c")), made on the
65+forkgram snapshot $(git rev-parse --short "$snap"), which is Forkgram $fg_version with the .github folder removed:
66+
67+ Forkgram commit $fg_commit
68+
69+Apply them onto a plain Forkgram checkout of that commit. Forkgram tags its releases
70+<version>.0 (so $fg_version.0 here); if the tag is missing, fetch the commit instead.
71+
72+ git clone --depth 1 --branch $fg_version.0 https://github.com/forkgram/TelegramAndroid.git
73+ cd TelegramAndroid
74+ git rev-parse HEAD # should print $fg_commit
75+ git switch -c multigram
76+ git am -3 $abs/*.patch
77+
78+-3 lets git fall back to a three-way merge when a patch does not apply as it is.
79+If git am still stops, it names the patch and the files: fix them, "git add" them and
80+run "git am --continue" (or "git am --skip" to leave that patch out, "git am --abort"
81+to go back). The same commands work on a newer Forkgram release (expect more stops);
82+clone without --depth 1 then, so -3 can find the older versions of the files.
83+EOF
84+
85+printf '%s\n' "Wrote $count patch(es) and APPLY.txt to $abs"
86+sed -n '/^ git clone/,/^ git am/p' "$dir/APPLY.txt"
scripts/selftest-sync.shnew file+646
@@ -0,0 +1,646 @@
1+#!/usr/bin/env bash
2+# Self-test for sync-forkgram.sh and export-patches.sh. Builds a fake Forkgram remote and a
3+# fake MultiGram remote in a temporary folder (nothing touches GitHub or your clone) and
4+# runs the sync through its cases: no new release, --force, a release that applies, a
5+# release that conflicts, the hand fix (rerere, a multigram-next the sync must not replace,
6+# --use-next and the check that it carries all of multigram), promote, the .github guard,
7+# a missing multigram branch, local branches that must not move, merge commits, a full
8+# clone that must stay full, and the patch export.
9+#
10+# bash scripts/selftest-sync.sh [--keep] (--keep leaves the folder for a look)
11+set -euo pipefail
12+
13+HERE=$(cd "$(dirname "$0")" && pwd)
14+SYNC=$HERE/sync-forkgram.sh
15+EXPORT=$HERE/export-patches.sh
16+T=$(mktemp -d "${TMPDIR:-/tmp}/multigram-selftest.XXXXXX")
17+KEEP=no
18+[ "${1:-}" != --keep ] || KEEP=yes
19+trap '[ $KEEP = yes ] && echo "left in $T" || rm -rf "$T"' EXIT
20+
21+# A clean git setup, whatever the user's own config says.
22+export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=$T/gitconfig
23+git config --global user.name "Self Test"
24+git config --global user.email selftest@example.invalid
25+git config --global init.defaultBranch main
26+git config --global advice.detachedHead false
27+unset GITHUB_ACTIONS GITHUB_OUTPUT GITHUB_STEP_SUMMARY RUNNER_TEMP FORKGRAM_URL FORKGRAM_BRANCH || true
28+export FORKGRAM_URL=file://$T/forkgram.git FORKGRAM_BRANCH=dev
29+
30+PASS=0
31+FAIL=0
32+ok() { PASS=$((PASS + 1)); printf ' ok %s\n' "$1"; }
33+bad() { FAIL=$((FAIL + 1)); printf ' FAIL %s\n' "$1"; }
34+eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1: got [$2], want [$3]"; fi; }
35+has() { case $2 in *"$3"*) ok "$1" ;; *) bad "$1: [$3] not in [$2]" ;; esac; }
36+title() { printf '\n== %s\n' "$*"; }
37+
38+# Runs the sync script; keeps its exit code in RC and its outputs in $T/out.
39+run() { # dir args...
40+ local d=$1
41+ shift
42+ : >"$T/out"
43+ RC=0
44+ (cd "$d" && GITHUB_OUTPUT=$T/out bash "$SYNC" "$@") >"$T/log" 2>&1 || RC=$?
45+ sed -e '/^---- outputs ----$/,$d' -e 's/^/ | /' "$T/log"
46+}
47+o() { # key -> value from the last run's outputs
48+ python3 - "$T/out" "$1" <<'PY'
49+import sys
50+path, key = sys.argv[1], sys.argv[2]
51+lines = open(path).read().split('\n')
52+vals, i = {}, 0
53+while i < len(lines):
54+ line = lines[i]
55+ if '<<' in line:
56+ k, d = line.split('<<', 1)
57+ j = lines.index(d, i + 1)
58+ vals[k] = '\n'.join(lines[i + 1:j])
59+ i = j + 1
60+ else:
61+ i += 1
62+print(vals.get(key, ''), end='')
63+PY
64+}
65+rref() { git --git-dir="$T/multigram.git" rev-parse -q --verify "$1" || true; }
66+
67+# ------------------------------------------------------------------ fake Forkgram
68+
69+git init -q --bare "$T/forkgram.git"
70+git --git-dir="$T/forkgram.git" config uploadpack.allowFilter true
71+git --git-dir="$T/forkgram.git" config uploadpack.allowAnySHA1InWant true
72+git init -q "$T/fg"
73+cd "$T/fg"
74+
75+main_java() { # name line, tail line
76+ printf 'class Main {\n String name = "%s";\n int a = 1;\n int b = 2;\n int c = 3;\n int d = 4;\n // %s\n}\n' "$1" "$2"
77+}
78+props() { printf 'APP_VERSION_CODE=%s\nAPP_VERSION_NAME=%s\nAPP_PACKAGE=org.example\n' "$1" "$2"; }
79+gitlink() { git update-index --add --cacheinfo "160000,$1,$2"; }
80+subst() { sed "$1" "$2" >"$2.new" && mv "$2.new" "$2"; } # sed -i, on any sed
81+setver() { subst "s/^APP_VERSION_NAME=.*/APP_VERSION_NAME=$1/" gradle.properties; }
82+upstream() { # version code name tail sublink : a Telegram "update to" commit
83+ mkdir -p app
84+ props "$2" "$1" >gradle.properties
85+ main_java "$3" "$4" >app/Main.java
86+ printf 'class Util {}\n' >app/Util.java
87+ printf '[submodule "lib/sub"]\n\tpath = lib/sub\n\turl = https://example.invalid/sub.git\n[submodule "topsub"]\n\tpath = topsub\n\turl = https://example.invalid/top.git\n' >.gitmodules
88+ git add -A
89+ gitlink "$5" lib/sub
90+ gitlink 2222222222222222222222222222222222222222 topsub
91+ git commit -q -m "update to $1 ($2)"
92+}
93+fork_commits() { # version : Forkgram's own commits, then the release commit
94+ mkdir -p .github/workflows
95+ printf 'on: push\n' >.github/workflows/ci.yml
96+ printf 'class Fork {}\n' >app/Fork.java
97+ git add -A
98+ git commit -q -m "Added fork settings."
99+ setver "$1"
100+ printf '// release %s\n' "$1" >>app/Fork.java
101+ git commit -q -am "[Release] $1."
102+}
103+
104+upstream 1.0.0 100 Fork "end" 1111111111111111111111111111111111111111
105+fork_commits 1.0.1
106+git push -q "$T/forkgram.git" HEAD:refs/heads/dev
107+R1=$(git rev-parse HEAD)
108+U1=$(git rev-parse HEAD~2)
109+
110+# ------------------------------------------------------------------ fake MultiGram
111+
112+git init -q --bare "$T/multigram.git"
113+git --git-dir="$T/multigram.git" config uploadpack.allowFilter true
114+git --git-dir="$T/multigram.git" config uploadpack.allowAnySHA1InWant true
115+git init -q "$T/mgsetup"
116+cd "$T/mgsetup"
117+git fetch -q "$T/forkgram.git" dev
118+tree=$(git ls-tree FETCH_HEAD | grep -v " .github$" | git mktree)
119+S1=$(printf 'Forkgram 1.0.1 snapshot\n\nSource: https://github.com/forkgram/TelegramAndroid/commit/%s\nForkgram-Commit: %s\nForkgram-Version: 1.0.1\nTelegram-Base: %s (update to 1.0.0)\n\nTree is Forkgram'"'"'s dev branch at that commit with the .github folder removed.\n' "$R1" "$R1" "$U1" | git commit-tree "$tree")
120+git switch -q -c multigram "$S1"
121+mkdir -p multigram
122+printf 'MultiGram notes\n' >multigram/README.md
123+git add -A && git commit -q -m "Add MultiGram notes"
124+subst 's/"Fork"/"MultiGram"/' app/Main.java
125+git commit -q -am "Rename the app"
126+printf 'class Util { /* MultiGram: hook */ }\n' >app/Util.java
127+git commit -q -am "Add a MultiGram hook"
128+git switch -q --orphan main
129+printf 'main\n' >README.md
130+git add README.md && git commit -q -m "Start main"
131+git push -q "$T/multigram.git" main "$S1:refs/heads/forkgram" multigram
132+MG0=$(rref multigram)
133+
134+# "CI": a blobless, depth-1 clone of main, like actions/checkout with filter: blob:none.
135+ci_clone() {
136+ rm -rf "$T/ci"
137+ git clone -q --filter=blob:none --depth 1 --branch main "file://$T/multigram.git" "$T/ci"
138+ git -C "$T/ci" config user.name "github-actions[bot]"
139+ git -C "$T/ci" config user.email "41898282+github-actions[bot]@users.noreply.github.com"
140+}
141+
142+# ------------------------------------------------------------------ A: no new release
143+title "A. No new Forkgram release"
144+ci_clone
145+run "$T/ci" --remote origin --push
146+eq "exit code" "$RC" 0
147+eq "status" "$(o status)" up-to-date
148+eq "new_snapshot" "$(o new_snapshot)" no
149+eq "forkgram unchanged" "$(rref forkgram)" "$S1"
150+eq "no multigram-next" "$(rref multigram-next)" ""
151+
152+# ------------------------------------------------------------------ B: --force
153+title "B. --force with no new release, then promote"
154+ci_clone
155+run "$T/ci" --remote origin --push --force
156+eq "exit code" "$RC" 0
157+eq "status" "$(o status)" candidate
158+eq "new_snapshot" "$(o new_snapshot)" no
159+eq "candidate is multigram (nothing to replay)" "$(o candidate)" "$MG0"
160+eq "multigram-next pushed" "$(rref multigram-next)" "$MG0"
161+eq "pushed" "$(o pushed)" multigram-next
162+cand=$(o candidate) prev=$(o multigram) tv=$(o tag_version)
163+ci_clone
164+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
165+eq "promote exit code" "$RC" 0
166+eq "promote status" "$(o status)" unchanged
167+eq "multigram unchanged" "$(rref multigram)" "$MG0"
168+eq "multigram-next deleted" "$(rref multigram-next)" ""
169+
170+# ------------------------------------------------------------------ C: clean release
171+title "C. New Forkgram release 1.1.0 that applies cleanly"
172+cd "$T/fg"
173+git reset -q --hard "$U1"
174+printf 'class Other {}\n' >app/Other.java
175+git add -A
176+upstream 1.1.0 110 Fork "end, 1.1.0" 3333333333333333333333333333333333333333
177+U2=$(git rev-parse HEAD)
178+fork_commits 1.1.0
179+R2=$(git rev-parse HEAD)
180+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
181+ci_clone
182+run "$T/ci" --remote origin --push
183+eq "exit code" "$RC" 0
184+eq "status" "$(o status)" candidate
185+eq "new_snapshot" "$(o new_snapshot)" yes
186+eq "forkgram_commit" "$(o forkgram_commit)" "$R2"
187+eq "forkgram_version" "$(o forkgram_version)" 1.1.0
188+eq "telegram_base" "$(o telegram_base)" "$U2 (update to 1.1.0)"
189+eq "previous_version" "$(o previous_version)" 1.0.1
190+S2=$(rref forkgram)
191+eq "forkgram pushed" "$S2" "$(o snapshot)"
192+eq "snapshot parent is the previous snapshot" "$(git --git-dir="$T/multigram.git" rev-parse "$S2^")" "$S1"
193+eq "snapshot has no .github" "$(git --git-dir="$T/multigram.git" ls-tree --name-only "$S2" .github)" ""
194+want=$(git -C "$T/fg" ls-tree "$R2" | grep -v " .github$")
195+eq "snapshot tree = Forkgram tree minus .github" "$(git --git-dir="$T/multigram.git" ls-tree "$S2")" "$want"
196+eq "gitlink lib/sub kept" "$(git --git-dir="$T/multigram.git" ls-tree "$S2" lib/sub | cut -f1)" "160000 commit 3333333333333333333333333333333333333333"
197+eq "gitlink topsub kept" "$(git --git-dir="$T/multigram.git" ls-tree "$S2" topsub | cut -f1)" "160000 commit 2222222222222222222222222222222222222222"
198+msg=$(git --git-dir="$T/multigram.git" log -1 --format=%B "$S2")
199+has "message title" "$msg" "Forkgram 1.1.0 snapshot"
200+has "message Source" "$msg" "Source: file://$T/forkgram.git (dev) at $R2"
201+has "message Forkgram-Commit" "$msg" "Forkgram-Commit: $R2"
202+has "message Forkgram-Version" "$msg" "Forkgram-Version: 1.1.0"
203+has "message Telegram-Base" "$msg" "Telegram-Base: $U2 (update to 1.1.0)"
204+has "message tail" "$msg" "Tree is Forkgram's dev branch at that commit with the .github folder removed."
205+C1=$(rref multigram-next)
206+eq "multigram-next = candidate" "$C1" "$(o candidate)"
207+eq "candidate has 3 commits on the snapshot" "$(git --git-dir="$T/multigram.git" rev-list --count "$S2..$C1")" 3
208+eq "candidate subjects" "$(git --git-dir="$T/multigram.git" log --reverse --format=%s "$S2..$C1" | tr '\n' '|')" "Add MultiGram notes|Rename the app|Add a MultiGram hook|"
209+eq "candidate Main.java: stack change on the new upstream" "$(git --git-dir="$T/multigram.git" show "$C1:app/Main.java" | sed -n '2p;7p' | tr '\n' '|')" ' String name = "MultiGram";| // end, 1.1.0|'
210+eq "candidate keeps upstream's new file" "$(git --git-dir="$T/multigram.git" show "$C1:app/Other.java")" "class Other {}"
211+eq "candidate tree = snapshot + stack diff" \
212+ "$(git --git-dir="$T/multigram.git" diff --stat "$S2" "$C1" | tail -n1)" \
213+ "$(git --git-dir="$T/multigram.git" diff --stat "$S1" "$MG0" | tail -n1)"
214+eq "multigram not moved yet" "$(rref multigram)" "$MG0"
215+eq "the sync recorded the multigram-next it built" "$(rref refs/multigram-sync/next)" "$C1"
216+cand=$(o candidate) prev=$(o multigram) tv=$(o tag_version)
217+ci_clone
218+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
219+eq "promote exit code" "$RC" 0
220+eq "promote status" "$(o status)" promoted
221+eq "multigram moved to the candidate" "$(rref multigram)" "$C1"
222+eq "old multigram tagged" "$(rref refs/tags/multigram-before-1.1.0)" "$MG0"
223+eq "multigram-next deleted" "$(rref multigram-next)" ""
224+
225+# ------------------------------------------------------------------ D: conflicting release
226+title "D. New Forkgram release 1.2.0 that conflicts"
227+cd "$T/fg"
228+git reset -q --hard "$U2"
229+upstream 1.2.0 120 "Fork 2" "end, 1.1.0" 3333333333333333333333333333333333333333
230+fork_commits 1.2.0
231+R3=$(git rev-parse HEAD)
232+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
233+MG1=$(rref multigram)
234+ci_clone
235+run "$T/ci" --remote origin --push
236+eq "exit code" "$RC" 2
237+eq "status" "$(o status)" conflict
238+S3=$(rref forkgram)
239+eq "snapshot pushed anyway" "$S3" "$(o snapshot)"
240+eq "forkgram_version" "$(o forkgram_version)" 1.2.0
241+eq "conflict commit is the rename" "$(o conflict_commit)" "$(git --git-dir="$T/multigram.git" rev-parse "$MG1~1")"
242+eq "conflict subject" "$(o conflict_subject)" "Rename the app"
243+eq "conflict position" "$(o conflict_position)" "2 of 3"
244+eq "conflict files" "$(o conflict_files)" app/Main.java
245+eq "conflict details" "$(o conflict_details)" "changed on both sides: app/Main.java"
246+eq "multigram untouched" "$(rref multigram)" "$MG1"
247+eq "no multigram-next" "$(rref multigram-next)" ""
248+eq "no candidate" "$(o candidate)" ""
249+eq "pushed" "$(o pushed)" forkgram
250+
251+# ------------------------------------------------------------------ E: behind
252+title "E. Next run, no new release: multigram is behind"
253+ci_clone
254+run "$T/ci" --remote origin --push
255+eq "exit code" "$RC" 0
256+eq "status" "$(o status)" behind
257+eq "multigram_base is the old snapshot" "$(o multigram_base)" "$S2"
258+eq "nothing pushed" "$(o pushed)" ""
259+
260+# ------------------------------------------------------------------ F: hand fix with rerere
261+title "F. Hand fix in a local clone; rerere replays it on the next local run"
262+git clone -q "file://$T/multigram.git" "$T/owner"
263+cd "$T/owner"
264+git config rerere.enabled true
265+git switch -q -C multigram-next origin/multigram
266+if git rebase -q --onto "$S3" "$S2" >/dev/null 2>&1; then bad "hand rebase should conflict"; fi
267+main_java "MultiGram 2" "end, 1.1.0" >app/Main.java
268+git add app/Main.java
269+GIT_EDITOR=true git rebase --continue >/dev/null 2>&1
270+FIX=$(git rev-parse HEAD)
271+eq "hand fix has 3 commits on the new snapshot" "$(git rev-list --count "$S3..$FIX")" 3
272+git switch -q --detach
273+git branch -q -D multigram-next
274+# The owner's own branches, with rebase.updateRefs on: the replay must not move them.
275+git config rebase.updateRefs true
276+git branch -q -f multigram origin/multigram
277+git branch -q -f wip origin/multigram~1
278+run "$T/owner" --remote origin --force
279+git config --unset rebase.updateRefs
280+eq "exit code" "$RC" 0
281+eq "status" "$(o status)" candidate
282+eq "local multigram not moved (rebase.updateRefs)" "$(git rev-parse multigram)" "$MG1"
283+eq "local wip not moved (rebase.updateRefs)" "$(git rev-parse wip)" "$(git rev-parse "$MG1~1")"
284+eq "local record = local multigram-next" "$(git rev-parse refs/multigram-sync/next)" "$(git rev-parse multigram-next)"
285+has "rerere_resolved names the file" "$(o rerere_resolved)" "app/Main.java"
286+eq "local multigram-next has the recorded resolution" "$(git show multigram-next:app/Main.java | sed -n 2p)" ' String name = "MultiGram 2";'
287+eq "same tree as the hand fix" "$(git rev-parse "multigram-next^{tree}")" "$(git rev-parse "$FIX^{tree}")"
288+eq "local forkgram = latest snapshot" "$(git rev-parse forkgram)" "$S3"
289+eq "nothing pushed without --push" "$(rref multigram-next)" ""
290+
291+# ------------------------------------------------------------------ G: --use-next
292+title "G. The hand fix on multigram-next: never replaced; --use-next checks it, then promote"
293+git push -q -f origin "$FIX:refs/heads/multigram-next"
294+ci_clone
295+run "$T/ci" --remote origin --push --force
296+eq "--force: exit code" "$RC" 0
297+eq "--force: status" "$(o status)" next-edited
298+eq "--force: existing_next" "$(o existing_next)" "$FIX"
299+eq "--force: multigram-next kept" "$(rref multigram-next)" "$FIX"
300+eq "--force: nothing pushed" "$(o pushed)" ""
301+cd "$T/fg"
302+setver 1.2.1
303+git commit -q -am "[Release] 1.2.1."
304+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
305+ci_clone
306+run "$T/ci" --remote origin --push
307+eq "new release: status" "$(o status)" next-edited
308+eq "new release: forkgram_tip is the new commit" "$(o forkgram_tip)" "$(git -C "$T/fg" rev-parse HEAD)"
309+eq "new release: no new snapshot" "$(rref forkgram)" "$S3"
310+eq "new release: multigram-next kept" "$(rref multigram-next)" "$FIX"
311+has "new release: message says why" "$(o message)" "has commits the sync did not make"
312+git reset -q --hard "$R3"
313+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
314+cd "$T/owner"
315+git fetch -q origin
316+git switch -q -C late origin/multigram
317+printf 'late\n' >app/Late.java
318+git add -A && GIT_AUTHOR_DATE="2030-01-01T00:00:00Z" git commit -q -m "Late MultiGram commit"
319+LATE=$(git rev-parse HEAD)
320+git push -q origin late:multigram
321+git switch -q --detach
322+git branch -q -D late
323+ci_clone
324+run "$T/ci" --remote origin --push --use-next
325+eq "late commit: exit code" "$RC" 1
326+eq "late commit: status" "$(o status)" error
327+eq "late commit: missing" "$(o missing)" "$LATE Late MultiGram commit"
328+has "late commit: error names it" "$(o error)" "Late MultiGram commit"
329+has "late commit: error offers allow_drop" "$(o error)" "allow_drop"
330+ci_clone
331+run "$T/ci" --remote origin --push --use-next --allow-drop
332+eq "--allow-drop: status" "$(o status)" candidate
333+eq "--allow-drop: dropped" "$(o dropped)" "$LATE Late MultiGram commit"
334+ci_clone
335+run "$T/ci" --remote origin --push --allow-drop
336+eq "--allow-drop without --use-next: exit code" "$RC" 1
337+git -C "$T/owner" push -q -f origin "$MG1:refs/heads/multigram"
338+ci_clone
339+run "$T/ci" --remote origin --push --use-next
340+eq "exit code" "$RC" 0
341+eq "status" "$(o status)" candidate
342+eq "candidate is the hand fix" "$(o candidate)" "$FIX"
343+eq "the hand fix carries the whole stack" "$(o missing)$(o dropped)" ""
344+eq "forkgram_version" "$(o forkgram_version)" 1.2.0
345+cand=$(o candidate) prev=$(o multigram) tv=$(o tag_version)
346+ci_clone
347+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
348+eq "promote status" "$(o status)" promoted
349+eq "multigram = hand fix" "$(rref multigram)" "$FIX"
350+eq "tag" "$(rref refs/tags/multigram-before-1.2.0)" "$MG1"
351+ci_clone
352+run "$T/ci" --remote origin --push
353+eq "and now up to date" "$(o status)" up-to-date
354+
355+# ------------------------------------------------------------------ H: dropped commit
356+title "H. Release 1.3.0 already has one MultiGram change: that commit is dropped"
357+cd "$T/fg"
358+git reset -q --hard "$R3"
359+mkdir -p multigram && printf 'MultiGram notes\n' >multigram/README.md
360+git add -A && git commit -q -m "Take the notes file"
361+setver 1.3.0
362+git commit -q -am "[Release] 1.3.0."
363+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
364+ci_clone
365+run "$T/ci" --remote origin --push
366+eq "status" "$(o status)" candidate
367+eq "telegram_base still found" "$(o telegram_base)" "$(git -C "$T/fg" rev-parse "HEAD~4") (update to 1.2.0)"
368+eq "stack_count" "$(o stack_count)" 3
369+eq "candidate_count" "$(o candidate_count)" 2
370+if [ -n "$(o dropped)" ]; then ok "dropped is reported"; else bad "dropped is empty"; fi
371+cand=$(o candidate) prev=$(o multigram) tv=$(o tag_version)
372+
373+# ------------------------------------------------------------------ I: promote lease
374+title "I. Promote refuses when multigram moved after the sync"
375+cd "$T/owner"
376+git fetch -q origin
377+git commit -q --allow-empty -m "Someone else's commit" && git push -q -f origin "HEAD:refs/heads/multigram"
378+ci_clone
379+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
380+eq "exit code" "$RC" 1
381+eq "status" "$(o status)" error
382+has "error says why" "$(o error)" "pushed to meanwhile"
383+eq "multigram-next kept" "$(rref multigram-next)" "$cand"
384+git push -q -f origin "$prev:refs/heads/multigram"
385+ci_clone
386+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
387+eq "promote after restoring multigram" "$(o status)" promoted
388+
389+# ------------------------------------------------------------------ J: .github guard
390+title "J. A stack commit that adds .github is refused"
391+cd "$T/owner"
392+git fetch -q origin
393+git switch -q -C multigram origin/multigram
394+mkdir -p .github && printf 'x\n' >.github/extra.yml
395+git add -A && git commit -q -m "Add a workflow file"
396+git push -q -f origin multigram
397+BAD=$(git rev-parse HEAD)
398+F_BEFORE=$(rref forkgram)
399+ci_clone
400+run "$T/ci" --remote origin --push --force
401+eq "exit code" "$RC" 1
402+eq "status" "$(o status)" error
403+has "error names .github" "$(o error)" ".github"
404+eq "nothing pushed" "$(rref multigram-next)" ""
405+eq "forkgram unchanged" "$(rref forkgram)" "$F_BEFORE"
406+git push -q -f origin "$BAD~1:refs/heads/multigram"
407+git switch -q --detach
408+
409+# ------------------------------------------------------------------ K: guards
410+title "K. Guards: branch checked out, no multigram branch"
411+git switch -q -C forkgram origin/forkgram
412+run "$T/owner" --force
413+eq "checked-out forkgram: exit code" "$RC" 1
414+has "checked-out forkgram: error" "$(o error)" "checked out in a worktree"
415+git switch -q --detach
416+git init -q --bare "$T/empty.git"
417+git -C "$T/owner" push -q "$T/empty.git" "origin/forkgram:refs/heads/forkgram"
418+git -C "$T/owner" remote add empty "$T/empty.git"
419+run "$T/owner" --remote empty --push
420+eq "no multigram: exit code" "$RC" 1
421+has "no multigram: error" "$(o error)" "has no multigram branch yet"
422+run "$T/owner" --remote empty --use-next --force
423+eq "--force with --use-next: exit code" "$RC" 1
424+git fetch -q origin
425+UNPUSHED=$(git commit-tree -p origin/multigram -m "Unpushed fix" "origin/multigram^{tree}")
426+git branch -q -f multigram-next "$UNPUSHED"
427+F_BEFORE=$(git rev-parse forkgram)
428+run "$T/owner" --remote origin --force
429+eq "unpushed local multigram-next: exit code" "$RC" 1
430+has "unpushed local multigram-next: error" "$(o error)" "on no other branch or tag"
431+eq "unpushed local multigram-next: not moved" "$(git rev-parse multigram-next)" "$UNPUSHED"
432+eq "unpushed local multigram-next: local forkgram not moved" "$(git rev-parse forkgram)" "$F_BEFORE"
433+git branch -q -D multigram-next
434+
435+# ------------------------------------------------------------------ L: local mode
436+title "L. Local branches, no remote"
437+cd "$T/owner"
438+git fetch -q origin
439+git branch -q -f forkgram origin/forkgram
440+git branch -q -f multigram origin/multigram
441+run "$T/owner"
442+eq "status" "$(o status)" up-to-date
443+HAND=$(git commit-tree -p multigram -m "Hand fix" "multigram^{tree}")
444+git branch -q -f multigram-next "$HAND"
445+run "$T/owner" --force
446+eq "hand-made local multigram-next: exit code" "$RC" 0
447+eq "hand-made local multigram-next: status" "$(o status)" next-edited
448+eq "hand-made local multigram-next: kept" "$(git rev-parse multigram-next)" "$HAND"
449+git branch -q -D multigram-next
450+
451+# ------------------------------------------------------------------ M: export-patches
452+title "M. export-patches.sh, applied onto a plain Forkgram checkout with git am -3"
453+cd "$T/owner"
454+EXP_RC=0
455+bash "$EXPORT" --base origin/forkgram --head origin/multigram "$T/patches" >"$T/log" 2>&1 || EXP_RC=$?
456+sed 's/^/ | /' "$T/log" | head -n 8
457+eq "export exit code" "$EXP_RC" 0
458+eq "patch files" "$(cd "$T/patches" && ls ./*.patch | wc -l | tr -d ' ')" 2
459+if [ -f "$T/patches/APPLY.txt" ]; then ok "APPLY.txt written"; else bad "APPLY.txt missing"; fi
460+has "APPLY.txt names the Forkgram commit" "$(cat "$T/patches/APPLY.txt")" "$(git -C "$T/fg" rev-parse HEAD)"
461+git clone -q -b dev "$T/forkgram.git" "$T/plain"
462+cd "$T/plain"
463+git switch -q -c multigram
464+if git am -q -3 "$T/patches"/*.patch >/dev/null 2>&1; then ok "git am -3 applies"; else bad "git am -3 failed"; fi
465+eq "result = multigram + Forkgram's .github" \
466+ "$(git ls-tree -r HEAD | grep -v ' .github/')" \
467+ "$(git -C "$T/owner" ls-tree -r origin/multigram)"
468+EXP_RC=0
469+bash "$EXPORT" --base origin/forkgram --head origin/multigram "$T/patches" >/dev/null 2>&1 || EXP_RC=$?
470+eq "refuses a non-empty folder without --force" "$EXP_RC" 1
471+
472+# ------------------------------------------------------------------ N: Forkgram already fetched
473+title "N. Forkgram's tip already in the local clone; a history too short for Telegram-Base"
474+cd "$T/fg"
475+printf 'class Later {}\n' >app/Later.java
476+git add -A && git commit -q -m "Added a later feature."
477+setver 1.5.0
478+git commit -q -am "[Release] 1.5.0."
479+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
480+R5=$(git rev-parse HEAD)
481+git clone -q "file://$T/multigram.git" "$T/full"
482+run "$T/full" --remote origin
483+eq "full clone: status" "$(o status)" candidate
484+has "full clone: fetched into a scratch repository" "$(cat "$T/log")" "into a scratch repository"
485+eq "full clone: still not shallow" "$(git -C "$T/full" rev-parse --is-shallow-repository)" false
486+eq "full clone: snapshot tree" "$(git -C "$T/full" ls-tree forkgram)" "$(git -C "$T/fg" ls-tree "$R5" | grep -v " .github$")"
487+eq "full clone: version from gradle.properties" "$(o forkgram_version)" 1.5.0
488+if git -C "$T/full" fsck --no-progress >"$T/fsck" 2>&1; then ok "full clone: fsck is clean"; else bad "full clone: fsck: $(head -n 3 "$T/fsck")"; fi
489+cd "$T/owner"
490+git fetch -q "$T/forkgram.git" "+refs/heads/dev:refs/forkgram-full/dev"
491+git fetch -q origin
492+git branch -q -f forkgram origin/forkgram
493+git branch -q -f multigram origin/multigram
494+FORKGRAM_HISTORY_DEPTH=1 run "$T/owner" --remote origin
495+eq "status" "$(o status)" candidate
496+has "used the local copy" "$(cat "$T/log")" "already here"
497+eq "no Telegram-Base when the history is too short" "$(o telegram_base)" ""
498+if git log -1 --format=%B forkgram | grep -q '^Telegram-Base:'; then bad "Telegram-Base line written"; else ok "no Telegram-Base line"; fi
499+eq "snapshot tree" "$(git ls-tree forkgram)" "$(git ls-tree "$R5" | grep -v " .github$")"
500+eq "local forkgram is the new snapshot, origin untouched" "$(git rev-parse forkgram^)" "$(rref forkgram)"
501+eq "not a shallow repository" "$(git rev-parse --is-shallow-repository)" false
502+
503+# ------------------------------------------------------------------ O: tag already taken
504+title "O. A second promotion for the same Forkgram version gets its own tag"
505+cd "$T/owner"
506+git fetch -q origin
507+git switch -q -C multigram-next origin/multigram
508+printf 'more\n' >>multigram/README.md 2>/dev/null || { mkdir -p multigram; printf 'more\n' >multigram/README.md; }
509+git add -A && git commit -q -m "Another MultiGram change"
510+git push -q -f origin multigram-next
511+git switch -q --detach
512+ci_clone
513+run "$T/ci" --remote origin --push --use-next
514+cand=$(o candidate) prev=$(o multigram) tv=$(o tag_version)
515+eq "same version as the last promotion" "$tv" 1.3.0
516+ci_clone
517+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
518+eq "status" "$(o status)" promoted
519+eq "tag with a suffix" "$(o tag)" multigram-before-1.3.0-2
520+eq "tag points at the old multigram" "$(rref refs/tags/multigram-before-1.3.0-2)" "$prev"
521+eq "first tag untouched" "$(rref refs/tags/multigram-before-1.3.0)" "$FIX"
522+
523+# ------------------------------------------------------------------ Q: merge commits
524+title "Q. Merge commits on multigram: a plain merge is flattened, one with its own changes stops the sync"
525+cd "$T/owner"
526+git fetch -q origin
527+MGQ=$(git rev-parse origin/multigram)
528+git switch -q -C side origin/forkgram
529+printf 'class Side {}\n' >app/Side.java
530+git add -A && git commit -q -m "Side feature"
531+git switch -q -C multigram "$MGQ"
532+git merge -q --no-ff --no-edit side
533+git push -q -f origin multigram
534+run "$T/owner" --remote origin --force
535+eq "plain merge: exit code" "$RC" 0
536+eq "plain merge: status" "$(o status)" candidate
537+has "plain merge: warned" "$(cat "$T/log")" "only join their parents"
538+eq "plain merge: candidate = snapshot + everything multigram changes" \
539+ "$(git diff "$(o snapshot)" multigram-next -- | grep -v '^index ')" "$(git diff origin/forkgram multigram -- | grep -v '^index ')"
540+eq "plain merge: candidate is linear" "$(git rev-list --merges "origin/forkgram..multigram-next")" ""
541+git reset -q --hard "$MGQ"
542+git merge -q --no-ff --no-commit side
543+printf 'class Glue {}\n' >app/Glue.java
544+git add -A && git commit -q -m "Merge side with glue"
545+git push -q -f origin multigram
546+run "$T/owner" --remote origin --force
547+eq "merge with changes: exit code" "$RC" 1
548+has "merge with changes: error names it" "$(o error)" "Merge side with glue"
549+git push -q -f origin "$MGQ:refs/heads/multigram"
550+git switch -q --detach
551+git branch -q -D side multigram
552+
553+# ------------------------------------------------------------------ P: sync-report.py
554+title "P. sync-report.py against a fake gh"
555+mkdir -p "$T/bin"
556+cat >"$T/bin/gh" <<'PY'
557+#!/usr/bin/env python3
558+# A tiny stand-in for the gh CLI: keeps issues in a JSON file.
559+import json, os, sys
560+path = os.environ['FAKE_GH_STATE']
561+st = json.load(open(path)) if os.path.exists(path) else {'issues': [], 'labels': []}
562+a = sys.argv[1:]
563+def opt(name):
564+ return a[a.index(name) + 1] if name in a else None
565+def body():
566+ f = opt('--body-file')
567+ return open(f).read() if f else ''
568+def issue(n):
569+ return next(i for i in st['issues'] if i['number'] == int(n))
570+if a[:2] == ['label', 'create']:
571+ st['labels'].append(a[2])
572+elif a[:2] == ['issue', 'list']:
573+ print(json.dumps([{'number': i['number'], 'title': i['title']} for i in st['issues']
574+ if i['state'] == 'open' and opt('--label') in i['labels']]))
575+elif a[:2] == ['issue', 'view']:
576+ i = issue(a[2])
577+ print(json.dumps({'body': i['body'], 'comments': [{'body': c} for c in i['comments']]}))
578+elif a[:2] == ['issue', 'create']:
579+ if opt('--label') not in st['labels']:
580+ sys.exit('label missing')
581+ n = len(st['issues']) + 1
582+ st['issues'].append({'number': n, 'title': opt('--title'), 'body': body(), 'labels': [opt('--label')],
583+ 'state': 'open', 'comments': []})
584+ print(f'https://example.invalid/issues/{n}')
585+elif a[:2] == ['issue', 'comment']:
586+ issue(a[2])['comments'].append(body())
587+elif a[:2] == ['issue', 'edit']:
588+ issue(a[2])['title'] = opt('--title')
589+elif a[:2] == ['issue', 'close']:
590+ i = issue(a[2]); i['state'] = 'closed'; i['comments'].append(opt('--comment'))
591+else:
592+ sys.exit('fake gh: unsupported ' + ' '.join(a))
593+json.dump(st, open(path, 'w'))
594+PY
595+chmod +x "$T/bin/gh"
596+export REPORT_GH=$T/bin/gh FAKE_GH_STATE=$T/gh.json RUN_URL=https://example.invalid/runs/1
597+report() { # VAR=value... : runs sync-report.py with only these job results and outputs
598+ env -i PATH="$PATH" REPORT_GH="$REPORT_GH" FAKE_GH_STATE="$FAKE_GH_STATE" RUN_URL="$RUN_URL" "$@" \
599+ python3 "$HERE/sync-report.py" 2>&1 | sed 's/^/ | /'
600+}
601+gh_state() { python3 -c "import json,sys; st=json.load(open('$T/gh.json')); print(eval(sys.argv[1]))" "$1"; }
602+A40=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa B40=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb C40=cccccccccccccccccccccccccccccccccccccccc
603+conflict=(SYNC_RESULT=failure COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=conflict
604+ SYNC_FORKGRAM_VERSION=1.2.0 SYNC_FORKGRAM_COMMIT=$A40 SYNC_SNAPSHOT=$B40 SYNC_PREVIOUS_SNAPSHOT=$C40
605+ SYNC_CONFLICT_COMMIT=$C40 "SYNC_CONFLICT_SUBJECT=Rename the \`app\`" "SYNC_CONFLICT_POSITION=2 of 3"
606+ SYNC_CONFLICT_FILES=app/Main.java "SYNC_CONFLICT_DETAILS=changed on both sides: app/Main.java")
607+report "${conflict[@]}"
608+eq "conflict opens an issue" "$(gh_state "len(st['issues'])")" 1
609+eq "issue title" "$(gh_state "st['issues'][0]['title']")" "Forkgram sync needs a hand (Forkgram 1.2.0)"
610+has "issue body has the rebase command" "$(gh_state "st['issues'][0]['body']")" "git rebase --onto $B40 $C40"
611+has "issue body has the file" "$(gh_state "st['issues'][0]['body']")" "changed on both sides: app/Main.java"
612+has "backticks in the subject are neutralised" "$(gh_state "st['issues'][0]['body']")" "Rename the 'app'"
613+report "${conflict[@]}"
614+eq "the same conflict is not reported twice" "$(gh_state "len(st['issues'][0]['comments'])")" 0
615+report SYNC_RESULT=success COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=behind SYNC_FORKGRAM_VERSION=1.2.0
616+eq "behind with an open issue adds nothing" "$(gh_state "len(st['issues'][0]['comments'])")" 0
617+report SYNC_RESULT=success COMPILE_RESULT=failure PROMOTE_RESULT=skipped SYNC_STATUS=candidate \
618+ SYNC_FORKGRAM_VERSION=1.2.0 SYNC_CANDIDATE=$A40 SYNC_SNAPSHOT=$B40
619+eq "compile failure comments on the open issue" "$(gh_state "len(st['issues'][0]['comments'])")" 1
620+has "comment has the autosquash fix" "$(gh_state "st['issues'][0]['comments'][0]")" "git rebase -i --autosquash $B40"
621+next_edited=(SYNC_RESULT=success COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=next-edited
622+ SYNC_FORKGRAM_VERSION=1.2.0 SYNC_FORKGRAM_COMMIT=$A40 SYNC_FORKGRAM_TIP=$B40 SYNC_EXISTING_NEXT=$C40)
623+report "${next_edited[@]}"
624+eq "next-edited comments on the open issue" "$(gh_state "len(st['issues'][0]['comments'])")" 2
625+has "next-edited comment says what to do" "$(gh_state "st['issues'][0]['comments'][1]")" "use_next"
626+has "next-edited comment names the new Forkgram commit" "$(gh_state "st['issues'][0]['comments'][1]")" "${B40:0:10}"
627+report "${next_edited[@]}"
628+eq "the same next-edited is not reported twice" "$(gh_state "len(st['issues'][0]['comments'])")" 2
629+report SYNC_RESULT=success COMPILE_RESULT=success PROMOTE_RESULT=success SYNC_STATUS=candidate \
630+ SYNC_FORKGRAM_VERSION=1.2.0 SYNC_CANDIDATE=$A40 PROMOTE_TAG=multigram-before-1.2.0 "SYNC_DROPPED=$C40 Late \`commit\`"
631+eq "promotion closes the issue" "$(gh_state "st['issues'][0]['state']")" closed
632+has "closing note names the tag" "$(gh_state "st['issues'][0]['comments'][-1]")" "multigram-before-1.2.0"
633+has "closing note lists the dropped commits" "$(gh_state "st['issues'][0]['comments'][-1]")" "$C40 Late 'commit'"
634+report SYNC_RESULT=success COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=up-to-date SYNC_FORKGRAM_VERSION=1.2.0
635+eq "up to date with no open issue: nothing" "$(gh_state "len(st['issues'])")" 1
636+report SYNC_RESULT=failure COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=error "SYNC_ERROR=origin has no multigram branch yet."
637+eq "a sync error opens a new issue" "$(gh_state "len(st['issues'])")" 2
638+has "error text in the issue" "$(gh_state "st['issues'][1]['body']")" "origin has no multigram branch yet."
639+report SYNC_RESULT=cancelled COMPILE_RESULT=skipped PROMOTE_RESULT=skipped
640+report SYNC_RESULT=success COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=up-to-date SYNC_FORKGRAM_VERSION=1.2.0
641+eq "in step again closes it" "$(gh_state "st['issues'][1]['state']")" closed
642+report SYNC_RESULT=success COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=behind SYNC_FORKGRAM_VERSION=1.3.0
643+eq "behind with no open issue opens one" "$(gh_state "[i['state'] for i in st['issues']]")" "['closed', 'closed', 'open']"
644+
645+printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
646+[ "$FAIL" -eq 0 ]
scripts/sync-forkgram.shnew file+848
@@ -0,0 +1,848 @@
1+#!/usr/bin/env bash
2+# Keep MultiGram in step with Forkgram.
3+#
4+# scripts/sync-forkgram.sh [sync] [--remote NAME [--push]] [--force | --use-next [--allow-drop]]
5+# [--url URL] [--branch NAME] [--history-depth N]
6+# scripts/sync-forkgram.sh promote --remote NAME --candidate SHA --previous SHA
7+# --version VERSION [--shallow]
8+#
9+# sync (the default)
10+# 1. Reads the tip of Forkgram's dev branch. If it is the commit the latest "forkgram"
11+# snapshot was made from, there is nothing to do (exit 0), unless --force.
12+# 2. Otherwise it makes a snapshot commit on "forkgram": the tip's tree without the
13+# .github folder (.gitmodules and the submodule links stay), parented on the previous
14+# snapshot, with the Forkgram-Commit, Forkgram-Version and Telegram-Base lines.
15+# 3. It replays the MultiGram stack (previous-snapshot..multigram) onto the snapshot with
16+# "git rebase --onto", in a temporary worktree, as branch "multigram-next". rerere is on,
17+# so conflicts already resolved in this clone are resolved the same way (a fresh CI clone
18+# has no recorded resolutions). On a conflict it aborts and reports the stack commit and
19+# the files (exit 2). The stack must be linear: a merge commit that carries changes of
20+# its own stops the sync, because the replay flattens merges.
21+# It never replaces a multigram-next that holds commits it did not make (a fix by hand):
22+# it then stops with status next-edited and changes nothing. refs/multigram-sync/next
23+# (pushed with multigram-next, and kept locally) records the last multigram-next it built.
24+# --remote NAME read forkgram and multigram from that remote (default: the local branches)
25+# --push push forkgram and multigram-next to NAME (one atomic push, with leases)
26+# --force no new Forkgram release: rebuild multigram-next on the current snapshot
27+# (tests the pipeline, or retries after a failed compile)
28+# --use-next take the multigram-next branch as it is (a stack fixed by hand) as the
29+# candidate; nothing is fetched from Forkgram and nothing is rebuilt. It
30+# must still carry every commit of multigram's stack (matched by author and
31+# subject, or by patch id); --allow-drop accepts one that leaves some out.
32+# The results are left in the local branches "forkgram" and "multigram-next". The
33+# checked-out worktree is never touched, a branch checked out anywhere is not moved, and
34+# a local multigram-next whose commits are on no other branch or tag is not moved either.
35+#
36+# promote
37+# Moves "multigram" to a candidate that passed the compile check: tags the old tip
38+# multigram-before-VERSION, moves multigram with --force-with-lease and deletes
39+# multigram-next, in one atomic push. --shallow fetches only the two tips.
40+#
41+# Environment: FORKGRAM_URL (default https://github.com/forkgram/TelegramAndroid),
42+# FORKGRAM_BRANCH (default dev), FORKGRAM_HISTORY_DEPTH (default 600: how many Forkgram
43+# commits to search for Telegram's "update to X" commit; 0 = do not search).
44+# When GITHUB_OUTPUT / GITHUB_STEP_SUMMARY are set, the outputs and a summary go there.
45+#
46+# Outputs (key=value, printed at the end and appended to $GITHUB_OUTPUT):
47+# status up-to-date | behind | next-edited | candidate | conflict | error (sync)
48+# promoted | unchanged | error (promote)
49+# message one line for people
50+# new_snapshot yes | no
51+# forkgram_commit, forkgram_version, telegram_base, snapshot
52+# the Forkgram commit, its version, its Telegram base and the forkgram
53+# snapshot the candidate is built on (new or current)
54+# previous_snapshot, previous_forkgram_commit, previous_version
55+# the forkgram tip before this run
56+# multigram the multigram tip the candidate was built from
57+# multigram_base the snapshot multigram is based on (differs from previous_snapshot
58+# when multigram is behind)
59+# stack_count MultiGram commits on multigram; candidate_count: on multigram-next
60+# candidate multigram-next's commit, empty when there is none
61+# existing_next the multigram-next this run found (next-edited: the one it left alone)
62+# tag_version the version, safe for a tag name
63+# dropped MultiGram commits the candidate leaves out ("sha subject" lines with
64+# --allow-drop, else git's lines about commits Forkgram already has)
65+# missing --use-next without --allow-drop: the commits multigram-next lacks
66+# rerere_resolved conflicts resolved from recorded resolutions ("commit path" lines)
67+# conflict_commit, conflict_subject, conflict_position,
68+# conflict_files (paths), conflict_details ("what happened: path" lines)
69+# forkgram_tip the tip of Forkgram's branch this run saw
70+# pushed the branches pushed; tag (promote): the tag made for the old multigram
71+# error what went wrong
72+# Exit codes: 0 done (see status), 1 error, 2 conflict.
73+set -euo pipefail
74+
75+FORKGRAM_URL=${FORKGRAM_URL:-https://github.com/forkgram/TelegramAndroid}
76+FORKGRAM_BRANCH=${FORKGRAM_BRANCH:-dev}
77+FORKGRAM_HISTORY_DEPTH=${FORKGRAM_HISTORY_DEPTH:-600}
78+
79+FG=forkgram
80+MG=multigram
81+NEXT=multigram-next
82+TIP_REF=refs/multigram-sync/forkgram-tip
83+REC_REF=refs/multigram-sync/next # the last multigram-next this script built by replaying
84+export GIT_TERMINAL_PROMPT=0
85+
86+NL=$'\n'
87+WORK=
88+WT=
89+ERROR_MSG=
90+OUT_KEYS=()
91+GITID=()
92+
93+# ---------------------------------------------------------------- output and logging
94+
95+log() { printf 'sync: %s\n' "$*" >&2; }
96+
97+annotate() { # level message
98+ if [ -n "${GITHUB_ACTIONS:-}" ]; then
99+ local m=$2
100+ m=${m//'%'/'%25'}; m=${m//$'\r'/'%0D'}; m=${m//$'\n'/'%0A'}
101+ printf '::%s::%s\n' "$1" "$m"
102+ else
103+ printf 'sync: %s: %s\n' "$1" "$2" >&2
104+ fi
105+}
106+
107+warn() { annotate warning "$*"; }
108+
109+die() {
110+ ERROR_MSG=$*
111+ out status error
112+ out error "$ERROR_MSG"
113+ out message "$ERROR_MSG"
114+ exit 1
115+}
116+
117+out() { # key value...
118+ local k=$1 have
119+ shift
120+ printf -v "o_$k" '%s' "$*"
121+ have=" ${OUT_KEYS[*]-} "
122+ case $have in *" $k "*) ;; *) OUT_KEYS+=("$k") ;; esac
123+}
124+
125+get() { local n="o_$1"; printf '%s' "${!n-}"; }
126+
127+random_hex() { od -An -N16 -tx1 /dev/urandom | tr -d ' \n'; }
128+
129+# Prints text that may hold other people's words (commit subjects, file names) so that the
130+# Actions runner does not read it as workflow commands.
131+print_untrusted() {
132+ if [ -n "${GITHUB_ACTIONS:-}" ]; then
133+ local token
134+ token=$(random_hex)
135+ printf '::stop-commands::%s\n%s\n::%s::\n' "$token" "$1" "$token"
136+ else
137+ printf '%s\n' "$1"
138+ fi
139+}
140+
141+emit_outputs() {
142+ [ ${#OUT_KEYS[@]} -gt 0 ] || return 0
143+ local k v delim text=
144+ delim=ghadelimiter_$(random_hex)
145+ for k in "${OUT_KEYS[@]}"; do
146+ v=$(get "$k")
147+ if [ -n "${GITHUB_OUTPUT:-}" ]; then
148+ printf '%s<<%s\n%s\n%s\n' "$k" "$delim" "$v" "$delim" >>"$GITHUB_OUTPUT"
149+ fi
150+ case $v in
151+ *$'\n'*) text+="$k:"$'\n'"$(printf '%s\n' "$v" | sed 's/^/ /')"$'\n' ;;
152+ *) text+="$k=$v"$'\n' ;;
153+ esac
154+ done
155+ print_untrusted "---- outputs ----"$'\n'"${text%$'\n'}"
156+}
157+
158+md() { printf '%s' "$1" | tr '`' "'"; } # untrusted text for a Markdown code span or block
159+
160+write_summary() {
161+ [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ ${#OUT_KEYS[@]} -gt 0 ] || return 0
162+ local k v
163+ {
164+ printf '### Forkgram sync: %s\n\n' "$(get status)"
165+ printf '```text\n%s\n```\n\n' "$(md "$(get message)")"
166+ printf '| | |\n|---|---|\n'
167+ for k in forkgram_version forkgram_commit telegram_base new_snapshot snapshot previous_version \
168+ previous_snapshot forkgram_tip multigram multigram_base stack_count existing_next candidate \
169+ candidate_count pushed tag; do
170+ v=$(get "$k")
171+ [ -z "$v" ] || printf '| %s | `%s` |\n' "$k" "$(md "$v" | tr '|' '/')"
172+ done
173+ for k in conflict_commit conflict_subject conflict_position conflict_details dropped missing rerere_resolved error; do
174+ v=$(get "$k")
175+ [ -z "$v" ] || printf '\n**%s**\n```text\n%s\n```\n' "$k" "$(md "$v")"
176+ done
177+ } >>"$GITHUB_STEP_SUMMARY"
178+}
179+
180+cleanup() {
181+ local rc=$?
182+ set +e
183+ if [ -n "$WT" ] && [ -e "$WT" ]; then
184+ git -C "$WT" rebase --abort >/dev/null 2>&1
185+ git worktree remove --force "$WT" >/dev/null 2>&1 || { rm -rf "$WT"; git worktree prune; }
186+ fi
187+ git update-ref -d "$TIP_REF" >/dev/null 2>&1
188+ [ -z "$WORK" ] || rm -rf "$WORK"
189+ if [ $rc -ne 0 ] && [ -z "$(get status)" ]; then
190+ out status error
191+ out error "${ERROR_MSG:-the script stopped unexpectedly (exit $rc); see the log above}"
192+ fi
193+ if [ "$(get status)" = error ]; then annotate error "$(get error)"; fi
194+ emit_outputs
195+ write_summary
196+ exit $rc
197+}
198+trap cleanup EXIT
199+
200+# ---------------------------------------------------------------- git helpers
201+
202+short() { git rev-parse --short "$1" 2>/dev/null || printf '%s' "${1:0:10}"; }
203+
204+enter_repo() {
205+ local top
206+ top=$(git rev-parse --show-toplevel 2>/dev/null) || die "Run this inside a clone of the MultiGram repository."
207+ cd "$top"
208+ WORK=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/multigram-sync.XXXXXX")
209+ # Commits made here (the snapshot, the replayed stack) need a committer identity.
210+ if ! git config user.email >/dev/null 2>&1; then
211+ GITID=(-c "user.name=MultiGram sync" -c "user.email=multigram-sync@users.noreply.github.com")
212+ fi
213+}
214+
215+# git for the commits this script makes: the fallback identity, and no signing (a local
216+# signing setup could prompt, or fail, in the middle of the replay).
217+g() { git -c commit.gpgSign=false ${GITID[@]+"${GITID[@]}"} "$@"; }
218+
219+remote_ref() { # "ls-remote output" refname -> sha or empty
220+ printf '%s\n' "$1" | awk -v r="$2" '$2 == r { print $1; exit }'
221+}
222+
223+check_free_branches() { # branch... : this script moves these branches
224+ local list b
225+ list=$(git worktree list --porcelain)
226+ for b; do
227+ if grep -qxF "branch refs/heads/$b" <<<"$list"; then
228+ die "The branch $b is checked out in a worktree. Switch that worktree to another branch first; this script moves $b."
229+ fi
230+ done
231+}
232+
233+set_branch() { # branch sha why
234+ local old
235+ old=$(git rev-parse -q --verify "refs/heads/$1" || true)
236+ [ "$old" != "$2" ] || return 0
237+ git update-ref -m "sync-forkgram: $3" "refs/heads/$1" "$2"
238+ if [ -n "$old" ]; then
239+ log "local branch $1: $(short "$old") -> $(short "$2")"
240+ else
241+ log "local branch $1: created at $(short "$2")"
242+ fi
243+}
244+
245+# True when replacing multigram-next loses nothing a person made: there is none, it is the
246+# one this script built last (the record), or multigram already has all of it.
247+next_is_ours() { # next record multigram
248+ [ -z "$1" ] || [ "$1" = "$2" ] || git merge-base --is-ancestor "$1" "$3" 2>/dev/null
249+}
250+
251+# Stops before the local multigram-next moves away from commits that are on no other
252+# branch, tag or record (say a fix by hand that was not pushed yet).
253+check_local_next() { # [the sha it would move to]
254+ local cur others
255+ cur=$(git rev-parse -q --verify "refs/heads/$NEXT" || true)
256+ [ -n "$cur" ] && [ "$cur" != "${1:-}" ] || return 0
257+ others=$(git for-each-ref --format='%(refname)' --contains "$cur" | grep -vxF "refs/heads/$NEXT" || true)
258+ [ -n "$others" ] ||
259+ die "Your local $NEXT ($(short "$cur")) has commits that are on no other branch or tag, so this script does not move it. Push it, or keep it under another name (git branch -m $NEXT <name>), then run again."
260+}
261+
262+# Prints "sha subject" for each commit of multigram's stack (base..mg) that the hand-made
263+# multigram-next (old..next) does not carry. A commit counts as carried when next has one
264+# with the same author (e-mail and time) and subject, which a rebase, a conflict fix, a
265+# reword-free fixup or git am all keep, or one with the same patch id.
266+missing_commits() { # base mg old next
267+ local f=$WORK/missing
268+ {
269+ git log --no-merges --no-color --no-ext-diff --format='commit %H' -p "$3..$4" | git patch-id --stable | awk '{ print "P\t" $1 }'
270+ git log --no-merges --format='N%x09%ae %at%x09%s' "$3..$4"
271+ git log --no-merges --no-color --no-ext-diff --format='commit %H' -p "$1..$2" | git patch-id --stable | awk '{ print "Q\t" $2 "\t" $1 }'
272+ git log --no-merges --reverse --format='M%x09%H%x09%ae %at%x09%s' "$1..$2"
273+ } >"$f"
274+ awk -F'\t' '
275+ $1 == "P" { pid[$2] = 1; next }
276+ $1 == "N" { k = $0; sub(/^[^\t]*\t/, "", k); have[k] = 1; next }
277+ $1 == "Q" { mine[$2] = $3; next }
278+ $1 == "M" {
279+ k = $0; sub(/^[^\t]*\t[^\t]*\t/, "", k)
280+ if ((k in have) || (($2 in mine) && (mine[$2] in pid))) next
281+ s = k; sub(/^[^\t]*\t/, "", s)
282+ print $2 " " s
283+ }' "$f"
284+}
285+
286+# The replay flattens merge commits (git rebase without --rebase-merges). That loses nothing
287+# when a merge only joins its parents, so those just get a warning; a merge with changes of
288+# its own (a conflict fix, glue code) would be lost, so it stops the sync.
289+check_merges() { # old mg
290+ local m parents tree bad= clean=0
291+ for m in $(git rev-list --merges "$1..$2"); do
292+ parents=$(git rev-list --parents -n 1 "$m" | cut -d' ' -f2-)
293+ if [ "$(printf '%s\n' $parents | grep -c .)" -eq 2 ] &&
294+ tree=$(git merge-tree --write-tree --no-messages $parents 2>/dev/null) &&
295+ [ "${tree%%"$NL"*}" = "$(git rev-parse "$m^{tree}")" ]; then
296+ clean=$((clean + 1))
297+ continue
298+ fi
299+ bad+="$(short "$m") $(git log -1 --format=%s "$m")$NL"
300+ done
301+ if [ -n "$bad" ]; then
302+ die "$MG has merge commits with changes of their own (the check needs git 2.38 or newer):$NL$bad""The sync replays the stack without merges, so those changes would be lost. Make $MG linear first and keep what the merges added as a commit of its own: git switch -C $MG origin/$MG && git rebase origin/$FG (fix any conflicts), then git restore --source=origin/$MG --staged --worktree :/ && git commit -m 'Keep the changes made in merges', and push it with git push --force-with-lease origin $MG."
303+ fi
304+ [ "$clean" -eq 0 ] || warn "$MG has $clean merge commit(s) on top of $FG that only join their parents; the replay flattens them into a linear stack."
305+}
306+
307+safe_version() { # prints the version, or "unknown" when it holds odd characters
308+ if [[ $1 =~ ^[0-9A-Za-z][0-9A-Za-z._+-]{0,39}$ ]]; then printf '%s' "$1"; else printf unknown; fi
309+}
310+
311+tag_version() { # version fallback -> a string that is safe in a tag name
312+ local v
313+ v=$(printf '%s' "$1" | tr -c '0-9A-Za-z._-' '-')
314+ if [ "$v" = unknown ] || ! git check-ref-format "refs/tags/multigram-before-$v"; then v=$2; fi
315+ printf '%s' "$v"
316+}
317+
318+has_github() { [ -n "$(git ls-tree --name-only "$1" -- .github)" ]; }
319+
320+check_no_github() { # from to what : every commit in from..to
321+ local c
322+ for c in $(git rev-list "$1..$2"); do
323+ if has_github "$c"; then
324+ die "Refusing to go on: $3 would contain a .github folder (commit $(short "$c")). The forkgram snapshots and the MultiGram stack must never carry .github (GITHUB_TOKEN cannot push workflow files, and the workflows live on main)."
325+ fi
326+ done
327+}
328+
329+# ---------------------------------------------------------------- snapshot helpers
330+
331+S_COMMIT= S_VERSION= S_BASE=
332+read_snapshot() { # commit : reads the Forkgram-* lines of a snapshot commit
333+ local body
334+ body=$(git log -1 --format=%B "$1")
335+ S_COMMIT=$(printf '%s\n' "$body" | sed -n 's/^Forkgram-Commit:[[:space:]]*\([0-9a-f]\{40\}\)[[:space:]]*$/\1/p' | tail -n 1)
336+ S_VERSION=$(printf '%s\n' "$body" | sed -n 's/^Forkgram-Version:[[:space:]]*//p' | tail -n 1 | tr -d '\r')
337+ S_BASE=$(printf '%s\n' "$body" | sed -n 's/^Telegram-Base:[[:space:]]*//p' | tail -n 1 | tr -d '\r')
338+ [ -n "$S_COMMIT" ] || return 1
339+ S_VERSION=$(safe_version "$S_VERSION")
340+}
341+
342+forkgram_version() { # tree subject -> APP_VERSION_NAME from gradle.properties, else the "[Release] X." subject
343+ local v
344+ v=$(git cat-file -p "$1:gradle.properties" 2>/dev/null |
345+ sed -n 's/^[[:space:]]*APP_VERSION_NAME[[:space:]]*=[[:space:]]*//p' | tail -n 1 | tr -d '\r' || true)
346+ v=${v%%[[:space:]]*}
347+ if [ -z "$v" ]; then
348+ v=$(printf '%s\n' "$2" | sed -n 's/^\[Release\][[:space:]]*\([0-9][0-9A-Za-z._-]*[0-9A-Za-z]\)\.\{0,1\}[[:space:]]*$/\1/p')
349+ fi
350+ safe_version "$v"
351+}
352+
353+TBASE=
354+find_telegram_base() { # tip : sets TBASE to "sha (update to X)" when Forkgram's history shows it
355+ local tip=$1 hist=$WORK/history.git lines line sha ver
356+ TBASE=
357+ [ "$FORKGRAM_HISTORY_DEPTH" -gt 0 ] || return 0
358+ # Commits only (no trees or files): cheap even a few hundred commits deep.
359+ if ! git clone --quiet --bare --filter=tree:0 --depth "$FORKGRAM_HISTORY_DEPTH" --single-branch \
360+ --branch "$FORKGRAM_BRANCH" --no-tags "$FORKGRAM_URL" "$hist" >"$WORK/history.log" 2>&1; then
361+ warn "Could not read Forkgram's history, so the snapshot has no Telegram-Base line."
362+ return 0
363+ fi
364+ if ! git -C "$hist" cat-file -e "$tip^{commit}" 2>/dev/null; then
365+ warn "Forkgram's $FORKGRAM_BRANCH moved during the sync, so the snapshot has no Telegram-Base line."
366+ return 0
367+ fi
368+ lines=$(git -C "$hist" log --first-parent --format='%H %s' "$tip")
369+ line=$(printf '%s\n' "$lines" | grep -m 1 -E '^[0-9a-f]{40} update to [0-9]' || true)
370+ if [ -z "$line" ]; then
371+ warn "No Telegram \"update to X\" commit in the last $FORKGRAM_HISTORY_DEPTH Forkgram commits, so the snapshot has no Telegram-Base line."
372+ return 0
373+ fi
374+ sha=${line%% *}
375+ ver=$(printf '%s\n' "${line#* }" | sed -n 's/^update to \([0-9][0-9A-Za-z._-]*\).*/\1/p')
376+ TBASE="$sha (update to $(safe_version "$ver"))"
377+}
378+
379+source_link() { # commit
380+ local u=${FORKGRAM_URL%/}
381+ u=${u%.git}
382+ case $u in
383+ https://github.com/*/*) printf '%s/commit/%s' "$u" "$1" ;;
384+ *) printf '%s (%s) at %s' "$FORKGRAM_URL" "$FORKGRAM_BRANCH" "$1" ;;
385+ esac
386+}
387+
388+snapshot_tree() { # tree -> the same tree without the top-level .github
389+ local entry
390+ while IFS= read -r -d '' entry; do
391+ [ "${entry#*$'\t'}" = .github ] && continue
392+ printf '%s\0' "$entry"
393+ done < <(git ls-tree -z "$1") | git mktree -z
394+}
395+
396+NEW_SNAPSHOT=
397+make_snapshot() { # tip tip_tree parent version telegram_base : sets NEW_SNAPSHOT
398+ local tip=$1 tree msg=$WORK/snapshot-message
399+ tree=$(snapshot_tree "$2") || die "Could not build the snapshot tree of $tip."
400+ {
401+ printf 'Forkgram %s snapshot\n\n' "$4"
402+ printf 'Source: %s\n' "$(source_link "$tip")"
403+ printf 'Forkgram-Commit: %s\n' "$tip"
404+ printf 'Forkgram-Version: %s\n' "$4"
405+ if [ -n "$5" ]; then printf 'Telegram-Base: %s\n' "$5"; fi
406+ printf "\\nTree is Forkgram's %s branch at that commit with the .github folder removed.\\n" "$FORKGRAM_BRANCH"
407+ } >"$msg"
408+ NEW_SNAPSHOT=$(g commit-tree "$tree" -p "$3" -F "$msg") || die "Could not create the snapshot commit."
409+}
410+
411+# Gets Forkgram's tip commit's files into this repository. Sets TIP (the commit, re-read
412+# from the fetch), TIP_TREE and TIP_SUBJECT.
413+TIP= TIP_TREE= TIP_SUBJECT=
414+fetch_tip() { # sha from ls-remote
415+ local tipgit=(git)
416+ TIP=$1
417+ if [ -z "$(git config extensions.partialClone || true)" ] && git cat-file -e "$TIP^{commit}" 2>/dev/null; then
418+ log "Forkgram ${TIP:0:10} is already here; not fetching it"
419+ elif [ "$(git rev-parse --is-shallow-repository)" = true ]; then
420+ # A shallow clone (as in CI) takes one more shallow commit in its stride.
421+ log "Fetching Forkgram ${TIP:0:10}"
422+ git fetch --no-tags --quiet --depth=1 "$FORKGRAM_URL" "+refs/heads/$FORKGRAM_BRANCH:$TIP_REF" ||
423+ die "Could not fetch Forkgram's $FORKGRAM_BRANCH from $FORKGRAM_URL."
424+ TIP=$(git rev-parse --verify "$TIP_REF^{commit}")
425+ else
426+ # A full clone: a shallow fetch would turn it into a shallow repository. Fetch into a
427+ # scratch repository instead and copy only the tip's tree (its files) into this one.
428+ log "Fetching Forkgram ${TIP:0:10} (into a scratch repository)"
429+ tipgit=(git -C "$WORK/tip.git")
430+ git init --quiet --bare "$WORK/tip.git"
431+ "${tipgit[@]}" fetch --no-tags --quiet --depth=1 "$FORKGRAM_URL" "+refs/heads/$FORKGRAM_BRANCH:refs/tip" ||
432+ die "Could not fetch Forkgram's $FORKGRAM_BRANCH from $FORKGRAM_URL."
433+ TIP=$("${tipgit[@]}" rev-parse --verify "refs/tip^{commit}")
434+ "${tipgit[@]}" rev-list --objects "$TIP^{tree}" | "${tipgit[@]}" pack-objects --quiet --stdout |
435+ git index-pack --stdin >/dev/null || die "Could not copy Forkgram's files into this repository."
436+ fi
437+ TIP_TREE=$("${tipgit[@]}" rev-parse --verify "$TIP^{tree}")
438+ TIP_SUBJECT=$("${tipgit[@]}" log -1 --format=%s "$TIP")
439+}
440+
441+set_snapshot_outputs() { # snapshot new_snapshot(yes|no)
442+ read_snapshot "$1" || die "The snapshot $(short "$1") has no Forkgram-Commit line."
443+ out snapshot "$1"
444+ out new_snapshot "$2"
445+ out forkgram_commit "$S_COMMIT"
446+ out forkgram_version "$S_VERSION"
447+ out telegram_base "$S_BASE"
448+ out tag_version "$(tag_version "$S_VERSION" "$(git rev-parse --short=10 "$S_COMMIT" 2>/dev/null || printf '%s' "${S_COMMIT:0:10}")")"
449+}
450+
451+# ---------------------------------------------------------------- the replay
452+
453+rebase_in_progress() {
454+ local d
455+ for d in rebase-merge rebase-apply; do
456+ [ ! -d "$(git -C "$WT" rev-parse --path-format=absolute --git-path "$d")" ] || return 0
457+ done
458+ return 1
459+}
460+
461+conflict_kinds() { # "stages<TAB>path" lines -> "what happened: path" lines
462+ awk -F'\t' '{
463+ k = $1
464+ if (k == "123") w = "changed on both sides"
465+ else if (k == "23") w = "added on both sides"
466+ else if (k == "13") w = "Forkgram deleted it, MultiGram changed it"
467+ else if (k == "12") w = "MultiGram deleted it, Forkgram changed it"
468+ else if (k == "2") w = "only on the Forkgram side (rename or delete conflict)"
469+ else if (k == "3") w = "only on the MultiGram side (rename or delete conflict)"
470+ else w = "conflict (stages " k ")"
471+ print w ": " $2
472+ }'
473+}
474+
475+# Replays upstream..tip onto onto in a temporary worktree. Sets REPLAY to "ok" and
476+# CANDIDATE, or to "conflict" and the conflict_* outputs.
477+CANDIDATE=
478+REPLAY=
479+replay() { # onto upstream tip
480+ local onto=$1 upstream=$2 tip=$3 rc=0 rounds=0 from=0 stopped unmerged stages files stack pos total resolved
481+ local rlog=$WORK/rebase.log
482+ WT=$WORK/replay
483+ git worktree add --quiet --detach "$WT" "$tip" >"$WORK/worktree.log" 2>&1 ||
484+ { cat "$WORK/worktree.log" >&2; die "Could not create a temporary worktree for the rebase."; }
485+ # The caller's git config must not change the replay: no update-refs (it would move the
486+ # caller's own branches), no merge-preserving rebase, no autostash, no hooks, no signing.
487+ local rb=(git -C "$WT" -c rerere.enabled=true -c rerere.autoUpdate=true -c rebase.updateRefs=false
488+ -c rebase.rebaseMerges=false -c rebase.autoStash=false -c rebase.backend=merge
489+ -c core.hooksPath=/dev/null -c commit.gpgSign=false ${GITID[@]+"${GITID[@]}"})
490+ log "Replaying $(git rev-list --count "$upstream..$tip") MultiGram commit(s) onto $(short "$onto")"
491+ # LC_ALL=C: the messages below are read back (rerere and dropped commits).
492+ GIT_EDITOR=true LC_ALL=C "${rb[@]}" rebase --no-autosquash --empty=drop --onto "$onto" "$upstream" >"$rlog" 2>&1 || rc=$?
493+ while [ $rc -ne 0 ]; do
494+ if ! rebase_in_progress; then
495+ print_untrusted "$(tail -n 40 "$rlog" | tr '\r' '\n')"
496+ die "git rebase failed without a conflict; see the log above."
497+ fi
498+ unmerged=$(git -C "$WT" ls-files -u)
499+ [ -z "$unmerged" ] || break
500+ # rerere resolved every conflict of this commit from a recorded resolution: go on.
501+ stopped=$(git -C "$WT" rev-parse -q --verify REBASE_HEAD || echo unknown)
502+ resolved=$(tail -c +$((from + 1)) "$rlog" | tr '\r' '\n' |
503+ sed -n -E "s/^(Resolved|Staged) '(.*)' using previous resolution\.\$/$(short "$stopped") \2/p")
504+ [ -n "$resolved" ] || resolved="$(short "$stopped") (files not named by git)"
505+ out rerere_resolved "$(get rerere_resolved)${o_rerere_resolved:+$NL}$resolved"
506+ rounds=$((rounds + 1))
507+ [ $rounds -le 1000 ] || die "git rebase keeps stopping; giving up."
508+ log "rerere resolved the conflicts in $(short "$stopped") from a recorded resolution; continuing"
509+ rc=0
510+ from=$(wc -c <"$rlog")
511+ GIT_EDITOR=true LC_ALL=C "${rb[@]}" rebase --continue >>"$rlog" 2>&1 || rc=$?
512+ done
513+
514+ local dropped
515+ dropped=$(tr '\r' '\n' <"$rlog" | grep -E '^(dropping |warning: skipped previously applied commit)' || true)
516+ [ -z "$dropped" ] || out dropped "$dropped"
517+
518+ if [ $rc -eq 0 ]; then
519+ CANDIDATE=$(git -C "$WT" rev-parse HEAD)
520+ REPLAY=ok
521+ return 0
522+ fi
523+
524+ stopped=$(git -C "$WT" rev-parse -q --verify REBASE_HEAD || true)
525+ stages=$(printf '%s\n' "$unmerged" | awk -F'\t' '{ split($1, a, " "); s[$2] = s[$2] a[3] } END { for (p in s) print s[p] "\t" p }' | sort -t "$(printf '\t')" -k 2)
526+ files=$(printf '%s\n' "$stages" | cut -f 2-)
527+ stack=$(git rev-list --reverse "$upstream..$tip")
528+ total=$(printf '%s\n' "$stack" | grep -c . || true)
529+ pos=$(printf '%s\n' "$stack" | grep -n -x -F "${stopped:-none}" | cut -d: -f1 || true)
530+ out conflict_commit "$stopped"
531+ out conflict_subject "$( [ -z "$stopped" ] || git log -1 --format=%s "$stopped")"
532+ out conflict_position "${pos:-?} of $total"
533+ out conflict_files "$files"
534+ out conflict_details "$(printf '%s\n' "$stages" | conflict_kinds)"
535+ print_untrusted "$(tr '\r' '\n' <"$rlog" | grep -v '^Rebasing (' | tail -n 40)"
536+ git -C "$WT" rebase --abort >/dev/null 2>&1 || true
537+ REPLAY=conflict
538+}
539+
540+# ---------------------------------------------------------------- sync
541+
542+sync_main() {
543+ local remote= push=no force=no use_next=no allow_drop=no
544+ while [ $# -gt 0 ]; do
545+ case $1 in
546+ --remote) remote=${2:?--remote needs a name}; shift 2 ;;
547+ --remote=*) remote=${1#*=}; shift ;;
548+ --push) push=yes; shift ;;
549+ --force) force=yes; shift ;;
550+ --use-next) use_next=yes; shift ;;
551+ --allow-drop) allow_drop=yes; shift ;;
552+ --url) FORKGRAM_URL=${2:?--url needs a URL}; shift 2 ;;
553+ --url=*) FORKGRAM_URL=${1#*=}; shift ;;
554+ --branch) FORKGRAM_BRANCH=${2:?--branch needs a name}; shift 2 ;;
555+ --branch=*) FORKGRAM_BRANCH=${1#*=}; shift ;;
556+ --history-depth) FORKGRAM_HISTORY_DEPTH=${2:?--history-depth needs a number}; shift 2 ;;
557+ --history-depth=*) FORKGRAM_HISTORY_DEPTH=${1#*=}; shift ;;
558+ -h | --help) sed -n '2,/^set -euo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
559+ *) die "Unknown option: $1 (see --help)" ;;
560+ esac
561+ done
562+ [ "$push" = no ] || [ -n "$remote" ] || die "--push needs --remote."
563+ [ "$force" = no ] || [ "$use_next" = no ] || die "--force and --use-next do not go together."
564+ [ "$allow_drop" = no ] || [ "$use_next" = yes ] || die "--allow-drop only goes with --use-next."
565+ [[ $FORKGRAM_HISTORY_DEPTH =~ ^[0-9]+$ ]] || die "--history-depth must be a number."
566+ enter_repo
567+ check_free_branches "$FG" "$NEXT"
568+
569+ # 1. Where forkgram, multigram and multigram-next are now, and the multigram-next this
570+ # script built last (rec_prev).
571+ local old mg next_prev= rec_prev=
572+ if [ -n "$remote" ]; then
573+ local heads specs what
574+ heads=$(git ls-remote "$remote" "refs/heads/$FG" "refs/heads/$MG" "refs/heads/$NEXT" "$REC_REF") ||
575+ die "Could not read the branches of $remote."
576+ [ -n "$(remote_ref "$heads" "refs/heads/$FG")" ] ||
577+ die "$remote has no $FG branch. It holds the Forkgram snapshots; create it first (see README.md)."
578+ [ -n "$(remote_ref "$heads" "refs/heads/$MG")" ] ||
579+ die "$remote has no $MG branch yet. Create it (the latest $FG snapshot plus the MultiGram commits) and push it; the sync replays that stack and cannot start without it."
580+ next_prev=$(remote_ref "$heads" "refs/heads/$NEXT")
581+ rec_prev=$(remote_ref "$heads" "$REC_REF")
582+ specs=("+refs/heads/$FG:refs/remotes/$remote/$FG" "+refs/heads/$MG:refs/remotes/$remote/$MG")
583+ what="$FG and $MG"
584+ if [ -n "$next_prev" ]; then
585+ specs+=("+refs/heads/$NEXT:refs/remotes/$remote/$NEXT")
586+ what="$FG, $MG and $NEXT"
587+ fi
588+ log "Fetching $what from $remote"
589+ git fetch --no-tags --quiet "$remote" "${specs[@]}" || die "Could not fetch $what from $remote."
590+ old=$(git rev-parse --verify "refs/remotes/$remote/$FG^{commit}")
591+ mg=$(git rev-parse --verify "refs/remotes/$remote/$MG^{commit}")
592+ [ -z "$next_prev" ] || next_prev=$(git rev-parse --verify "refs/remotes/$remote/$NEXT^{commit}")
593+ else
594+ old=$(git rev-parse -q --verify "refs/heads/$FG^{commit}") ||
595+ die "There is no $FG branch here. It holds the Forkgram snapshots; fetch it or pass --remote."
596+ mg=$(git rev-parse -q --verify "refs/heads/$MG^{commit}") ||
597+ die "There is no $MG branch yet. Create it (the latest $FG snapshot plus the MultiGram commits); the sync replays that stack and cannot start without it."
598+ next_prev=$(git rev-parse -q --verify "refs/heads/$NEXT^{commit}" || true)
599+ rec_prev=$(git rev-parse -q --verify "$REC_REF^{commit}" || true)
600+ fi
601+ out existing_next "$next_prev"
602+
603+ # 2. The latest snapshot and the stack on top of it.
604+ read_snapshot "$old" ||
605+ die "The tip of $FG ($(short "$old")) has no Forkgram-Commit line, so it is not a Forkgram snapshot."
606+ local old_commit=$S_COMMIT old_version=$S_VERSION base behind stack_count
607+ out previous_snapshot "$old"
608+ out previous_forkgram_commit "$old_commit"
609+ out previous_version "$old_version"
610+ out multigram "$mg"
611+ base=$(git merge-base "$old" "$mg") || die "$MG shares no history with $FG."
612+ out multigram_base "$base"
613+ if [ "$base" = "$old" ]; then behind=no; else behind=yes; fi
614+ if [ "$behind" = yes ] && ! read_snapshot "$base"; then
615+ warn "$MG is based on $(short "$base"), which is not a $FG snapshot."
616+ fi
617+ stack_count=$(git rev-list --count "$old..$mg")
618+ out stack_count "$stack_count"
619+ [ "$stack_count" -gt 0 ] || warn "$MG has no commits on top of $FG."
620+
621+ # 3a. A stack fixed by hand, already pushed to multigram-next.
622+ if [ "$use_next" = yes ]; then
623+ local missing n
624+ [ -n "$next_prev" ] || die "There is no $NEXT branch. Push the fixed stack to $NEXT first (see README.md)."
625+ git merge-base --is-ancestor "$old" "$next_prev" ||
626+ die "$NEXT is not based on the latest $FG snapshot ($(short "$old"), Forkgram $old_version). Rebase it onto $FG first."
627+ [ "$next_prev" != "$old" ] || die "$NEXT has no commits on top of $FG."
628+ set_snapshot_outputs "$old" no
629+ check_no_github "$old" "$next_prev" "$NEXT"
630+ # Everything on multigram must still be there: commits pushed to multigram after the fix
631+ # was started would otherwise leave the app branch at the promotion.
632+ missing=$(missing_commits "$base" "$mg" "$old" "$next_prev")
633+ if [ -n "$missing" ]; then
634+ n=$(printf '%s\n' "$missing" | grep -c .)
635+ if [ "$allow_drop" = yes ]; then
636+ out dropped "$missing"
637+ warn "$NEXT leaves out $n of $MG's commit(s); --allow-drop accepts that (see the dropped output)."
638+ else
639+ out missing "$missing"
640+ die "$NEXT does not carry $n of $MG's commit(s):$NL$missing${NL}They were probably pushed to $MG after the fix on $NEXT was started. Add them to $NEXT (git switch $NEXT && git cherry-pick <commit>...), push it and run with use_next again. If $NEXT leaves them out on purpose (Forkgram has them now, or you folded or dropped them), run use_next with allow_drop (--allow-drop)."
641+ fi
642+ fi
643+ CANDIDATE=$next_prev
644+ out candidate "$CANDIDATE"
645+ out candidate_count "$(git rev-list --count "$old..$CANDIDATE")"
646+ check_local_next "$CANDIDATE"
647+ set_branch "$FG" "$old" "Forkgram $old_version snapshot"
648+ set_branch "$NEXT" "$CANDIDATE" "use multigram-next as the candidate"
649+ out pushed ""
650+ out status candidate
651+ out message "Using $NEXT ($(short "$CANDIDATE")) as it is: $(get candidate_count) commit(s) on the Forkgram $old_version snapshot."
652+ log "$(get message)"
653+ return 0
654+ fi
655+
656+ # 3b. Forkgram's tip.
657+ local tip new new_snapshot tbase version
658+ log "Reading $FORKGRAM_URL ($FORKGRAM_BRANCH)"
659+ tip=$(git ls-remote "$FORKGRAM_URL" "refs/heads/$FORKGRAM_BRANCH") || die "Could not reach Forkgram at $FORKGRAM_URL."
660+ tip=$(remote_ref "$tip" "refs/heads/$FORKGRAM_BRANCH")
661+ [ -n "$tip" ] || die "Forkgram has no $FORKGRAM_BRANCH branch at $FORKGRAM_URL."
662+ out forkgram_tip "$tip"
663+
664+ if [ "$tip" = "$old_commit" ] && [ "$force" = no ]; then
665+ set_snapshot_outputs "$old" no
666+ out candidate ""
667+ out pushed ""
668+ if [ "$behind" = no ]; then
669+ out status up-to-date
670+ out message "Nothing to do: Forkgram's $FORKGRAM_BRANCH is still $(short "$tip") (Forkgram $old_version), the latest $FG snapshot, and $MG is based on it."
671+ log "$(get message)"
672+ else
673+ out status behind
674+ out message "No new Forkgram release, but $MG is still based on $(short "$base"), not on the Forkgram $old_version snapshot ($(short "$old")): the last sync could not move it. Fix the stack on $NEXT and run with --use-next, or retry with --force."
675+ warn "$(get message)"
676+ fi
677+ return 0
678+ fi
679+
680+ # From here on the run builds a new multigram-next. It never replaces one that holds
681+ # commits it did not make (a fix by hand): that one waits for --use-next, or for someone
682+ # to delete it. Nothing is snapshotted meanwhile, so the fix stays based on the latest
683+ # snapshot and --use-next can take it.
684+ if ! next_is_ours "$next_prev" "$rec_prev" "$mg"; then
685+ set_snapshot_outputs "$old" no
686+ out candidate ""
687+ out pushed ""
688+ out status next-edited
689+ if [ "$tip" = "$old_commit" ]; then
690+ out message "$NEXT ($(short "$next_prev")) has commits the sync did not make (a fix by hand?), so --force does not replace it. Run with --use-next to compile and promote it, or delete $NEXT to let the sync rebuild it from $MG."
691+ else
692+ out message "Forkgram's $FORKGRAM_BRANCH has moved to $(short "$tip"), but $NEXT ($(short "$next_prev")) has commits the sync did not make (a fix by hand?), so the sync changes nothing: no new snapshot, no new $NEXT. Run with --use-next to compile and promote $NEXT, or delete it to let the sync rebuild it from $MG; the next run then takes the new Forkgram commit."
693+ fi
694+ warn "$(get message)"
695+ return 0
696+ fi
697+ check_local_next
698+ check_merges "$old" "$mg"
699+
700+ if [ "$tip" = "$old_commit" ]; then
701+ log "No new Forkgram release; --force: rebuilding $NEXT on the current snapshot"
702+ new=$old
703+ new_snapshot=no
704+ else
705+ fetch_tip "$tip"
706+ [ "$TIP" != "$old_commit" ] ||
707+ die "Forkgram's $FORKGRAM_BRANCH moved back to the latest snapshot's commit during the sync; run it again."
708+ tip=$TIP
709+ out forkgram_tip "$tip"
710+ version=$(forkgram_version "$TIP_TREE" "$TIP_SUBJECT")
711+ [ "$version" != unknown ] || warn "Could not read the Forkgram version (APP_VERSION_NAME in gradle.properties)."
712+ find_telegram_base "$tip"
713+ tbase=$TBASE
714+ make_snapshot "$tip" "$TIP_TREE" "$old" "$version" "$tbase"
715+ new=$NEW_SNAPSHOT
716+ new_snapshot=yes
717+ log "New snapshot $(short "$new"): Forkgram $version ($(short "$tip")), Telegram base: ${tbase:-not found}"
718+ if [ "$version" = "$old_version" ]; then
719+ warn "Forkgram's $FORKGRAM_BRANCH moved but its version is still $version."
720+ fi
721+ fi
722+ set_snapshot_outputs "$new" "$new_snapshot"
723+ version=$(get forkgram_version)
724+ check_no_github "$old" "$new" "$FG"
725+
726+ # 4. The replay.
727+ replay "$new" "$old" "$mg"
728+ if [ "$REPLAY" = ok ]; then
729+ check_no_github "$new" "$CANDIDATE" "$NEXT"
730+ check_local_next "$CANDIDATE"
731+ fi
732+ set_branch "$FG" "$new" "Forkgram $version snapshot"
733+ if [ "$REPLAY" = ok ]; then
734+ set_branch "$NEXT" "$CANDIDATE" "replay of $MG onto Forkgram $version"
735+ git update-ref -m "sync-forkgram: the $NEXT it built" "$REC_REF" "$CANDIDATE"
736+ out candidate "$CANDIDATE"
737+ out candidate_count "$(git rev-list --count "$new..$CANDIDATE")"
738+ else
739+ out candidate ""
740+ fi
741+
742+ # 5. The push. refs/multigram-sync/next records the multigram-next the sync built, so a
743+ # later run can tell it from one changed by hand.
744+ local refspecs=() leases=() pushed=()
745+ if [ "$new_snapshot" = yes ]; then
746+ refspecs+=("$new:refs/heads/$FG")
747+ leases+=("--force-with-lease=refs/heads/$FG:$old")
748+ pushed+=("$FG")
749+ fi
750+ if [ -n "$CANDIDATE" ]; then
751+ refspecs+=("$CANDIDATE:refs/heads/$NEXT" "$CANDIDATE:$REC_REF")
752+ leases+=("--force-with-lease=refs/heads/$NEXT:$next_prev" "--force-with-lease=$REC_REF:$rec_prev")
753+ pushed+=("$NEXT")
754+ fi
755+ if [ "$push" = yes ] && [ ${#refspecs[@]} -gt 0 ]; then
756+ log "Pushing ${pushed[*]} to $remote"
757+ git push --atomic --quiet "${leases[@]}" "$remote" "${refspecs[@]}" || die "The push of ${pushed[*]} to $remote failed."
758+ out pushed "${pushed[*]}"
759+ else
760+ out pushed ""
761+ fi
762+
763+ if [ "$REPLAY" = ok ]; then
764+ out status candidate
765+ out message "$NEXT ($(short "$CANDIDATE")) is $MG's $(get stack_count) commit(s) replayed onto Forkgram $version ($(short "$(get forkgram_commit)")); $(get candidate_count) commit(s) on top of the snapshot."
766+ log "$(get message)"
767+ [ -z "$(get dropped)" ] || warn "Some MultiGram commits were left out because Forkgram already has them; see the dropped output."
768+ return 0
769+ fi
770+ out status conflict
771+ out message "The MultiGram stack does not apply to Forkgram $version: commit $(short "$(get conflict_commit)") ($(get conflict_position)) conflicts in $(printf '%s\n' "$(get conflict_files)" | grep -c .) file(s). $MG is unchanged."
772+ annotate error "$(get message)"
773+ return 2
774+}
775+
776+# ---------------------------------------------------------------- promote
777+
778+promote_main() {
779+ local remote= cand= prev= version= shallow=no
780+ while [ $# -gt 0 ]; do
781+ case $1 in
782+ --remote) remote=${2:?}; shift 2 ;;
783+ --candidate) cand=${2:?}; shift 2 ;;
784+ --previous) prev=${2:?}; shift 2 ;;
785+ --version) version=${2:?}; shift 2 ;;
786+ --shallow) shallow=yes; shift ;;
787+ -h | --help) sed -n '2,/^set -euo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
788+ *) die "Unknown option: $1 (see --help)" ;;
789+ esac
790+ done
791+ [ -n "$remote" ] || die "promote needs --remote."
792+ [[ $cand =~ ^[0-9a-f]{40}$ ]] || die "promote needs --candidate with a full commit id."
793+ [[ $prev =~ ^[0-9a-f]{40}$ ]] || die "promote needs --previous with a full commit id."
794+ [[ $version =~ ^[0-9A-Za-z._-]+$ ]] || die "promote needs --version (letters, digits, dot, dash)."
795+ enter_repo
796+
797+ local heads now_mg now_next depth=() tag base n existing
798+ heads=$(git ls-remote "$remote" "refs/heads/$MG" "refs/heads/$NEXT") || die "Could not read the branches of $remote."
799+ now_mg=$(remote_ref "$heads" "refs/heads/$MG")
800+ now_next=$(remote_ref "$heads" "refs/heads/$NEXT")
801+ [ "$now_mg" = "$prev" ] ||
802+ die "$MG on $remote is ${now_mg:-missing}, not $prev that the candidate was built from: it was pushed to meanwhile. Not promoting."
803+ [ "$now_next" = "$cand" ] ||
804+ die "$NEXT on $remote is ${now_next:-missing}, not the compiled candidate $cand. Not promoting."
805+ [ "$shallow" = no ] || depth=(--depth=1)
806+ git fetch --no-tags --quiet ${depth[@]+"${depth[@]}"} "$remote" \
807+ "+refs/heads/$MG:refs/remotes/$remote/$MG" "+refs/heads/$NEXT:refs/remotes/$remote/$NEXT" ||
808+ die "Could not fetch $MG and $NEXT from $remote."
809+ ! has_github "$cand" || die "Refusing to promote: the candidate contains a .github folder."
810+ out candidate "$cand"
811+ out previous_multigram "$prev"
812+
813+ if [ "$cand" = "$prev" ]; then
814+ git push --atomic --quiet "--force-with-lease=refs/heads/$NEXT:$cand" "$remote" ":refs/heads/$NEXT" ||
815+ die "Could not delete $NEXT on $remote."
816+ out status unchanged
817+ out tag ""
818+ out message "$MG already is the candidate ($(short "$cand")); deleted $NEXT."
819+ log "$(get message)"
820+ return 0
821+ fi
822+
823+ base=multigram-before-$version
824+ existing=$(git ls-remote --tags "$remote" "refs/tags/$base*" | awk '{ print $2 }') ||
825+ die "Could not read the tags of $remote."
826+ tag=$base
827+ n=1
828+ while grep -qxF "refs/tags/$tag" <<<"$existing"; do
829+ n=$((n + 1))
830+ tag=$base-$n
831+ done
832+ log "Tagging $(short "$prev") as $tag, moving $MG to $(short "$cand"), deleting $NEXT"
833+ git push --atomic --quiet \
834+ "--force-with-lease=refs/heads/$MG:$prev" "--force-with-lease=refs/heads/$NEXT:$cand" \
835+ "$remote" "$cand:refs/heads/$MG" "$prev:refs/tags/$tag" ":refs/heads/$NEXT" ||
836+ die "The push that moves $MG failed; $MG is unchanged."
837+ out status promoted
838+ out tag "$tag"
839+ out message "$MG moved from $(short "$prev") (tagged $tag) to $(short "$cand"); $NEXT deleted."
840+ log "$(get message)"
841+}
842+
843+cmd=sync
844+case ${1:-} in
845+ sync | promote) cmd=$1; shift ;;
846+esac
847+# A conflict makes sync_main return 2, which (set -e) ends the script with exit code 2.
848+"${cmd}_main" "$@"
scripts/sync-report.pynew file+273
@@ -0,0 +1,273 @@
1+#!/usr/bin/env python3
2+"""Report a "Sync with Forkgram" run on the GitHub issue labelled forkgram-sync.
3+
4+Runs in the report job of .github/workflows/sync-forkgram.yml, after the sync, compile and
5+promote jobs. It reads their results and the sync outputs from the environment and:
6+
7+ - conflict, compile failure, promote failure, a sync error, or a multigram-next changed by
8+ hand that the sync would have replaced: opens an issue, or comments on the open one, with
9+ what happened and the local commands to fix it (the same event is reported once, however
10+ often the schedule runs);
11+ - multigram still behind with no new release: opens an issue only if none is open;
12+ - promoted, or multigram in step with Forkgram: closes the open issue with a note (naming
13+ any MultiGram commits the new multigram leaves out).
14+
15+Environment: SYNC_RESULT, COMPILE_RESULT, PROMOTE_RESULT (job results); SYNC_* (the sync
16+job's outputs, e.g. SYNC_STATUS, SYNC_FORKGRAM_VERSION); PROMOTE_TAG; RUN_URL; GH_TOKEN
17+and GH_REPO for the gh CLI. REPORT_GH names another gh binary (the self-test uses a fake).
18+Standard library only.
19+"""
20+import hashlib
21+import json
22+import os
23+import re
24+import subprocess
25+import sys
26+import tempfile
27+
28+LABEL = 'forkgram-sync'
29+GH = os.environ.get('REPORT_GH', 'gh')
30+SHA = re.compile(r'^[0-9a-f]{40}$')
31+VERSION = re.compile(r'^[0-9A-Za-z][0-9A-Za-z._+-]{0,39}$')
32+
33+
34+def env(name):
35+ return os.environ.get(name, '').strip()
36+
37+
38+def sha(name):
39+ value = env(name)
40+ return value if SHA.match(value) else ''
41+
42+
43+def short(value):
44+ return value[:10] if value else '?'
45+
46+
47+def version():
48+ value = env('SYNC_FORKGRAM_VERSION')
49+ return value if VERSION.match(value) else 'unknown'
50+
51+
52+def block(text):
53+ """Untrusted text (commit subjects, file names, errors) as a Markdown code block."""
54+ text = text.replace('`', "'").strip() or '(none)'
55+ return '```text\n' + text + '\n```'
56+
57+
58+def gh(*args):
59+ run = subprocess.run([GH, *args], capture_output=True, text=True)
60+ if run.returncode != 0:
61+ sys.stderr.write(run.stderr)
62+ raise SystemExit(f'gh {args[0]} {args[1] if len(args) > 1 else ""} failed')
63+ return run.stdout
64+
65+
66+def gh_body(*args, body):
67+ with tempfile.NamedTemporaryFile('w', suffix='.md', delete=False) as f:
68+ f.write(body)
69+ path = f.name
70+ try:
71+ return gh(*args, '--body-file', path)
72+ finally:
73+ os.unlink(path)
74+
75+
76+def open_issues():
77+ issues = json.loads(gh('issue', 'list', '--label', LABEL, '--state', 'open',
78+ '--json', 'number,title', '--limit', '20') or '[]')
79+ return sorted(i['number'] for i in issues)
80+
81+
82+def open_issue():
83+ numbers = open_issues()
84+ return numbers[0] if numbers else None
85+
86+
87+def already_reported(number, key):
88+ data = json.loads(gh('issue', 'view', str(number), '--json', 'body,comments'))
89+ texts = [data.get('body') or ''] + [c.get('body') or '' for c in data.get('comments', [])]
90+ return any(key in t for t in texts)
91+
92+
93+def marker(key):
94+ return f'<!-- forkgram-sync-key: {key} -->'
95+
96+
97+def title():
98+ return f'Forkgram sync needs a hand (Forkgram {version()})'
99+
100+
101+def run_line():
102+ url = env('RUN_URL')
103+ return f'Run: {url}' if url.startswith('https://') else ''
104+
105+
106+def use_next_step():
107+ return ('Then open **Actions > Sync with Forkgram > Run workflow**, tick **use_next** and run '
108+ 'it. It compiles `multigram-next` and, if that passes, moves `multigram` to it (the '
109+ 'old tip is kept as the tag `multigram-before-<version>`) and closes this issue.')
110+
111+
112+def body_conflict():
113+ snap, prev = sha('SYNC_SNAPSHOT') or '<new snapshot>', sha('SYNC_PREVIOUS_SNAPSHOT') or '<old snapshot>'
114+ commit = sha('SYNC_CONFLICT_COMMIT')
115+ return f"""**Forkgram {version()}** is out and is now on `forkgram` (snapshot `{short(snap)}`), but the MultiGram stack does not apply to it. `multigram` is unchanged.
116+
117+MultiGram commit `{short(commit)}` ({env('SYNC_CONFLICT_POSITION') or '?'}) stops the rebase:
118+{block(env('SYNC_CONFLICT_SUBJECT'))}
119+Files:
120+{block(env('SYNC_CONFLICT_DETAILS') or env('SYNC_CONFLICT_FILES'))}
121+{run_line()}
122+
123+### Fix it locally
124+
125+```sh
126+git fetch origin
127+git switch -C multigram-next origin/multigram
128+git config rerere.enabled true # reuses your fixes if you redo this rebase in this clone
129+git rebase --onto {snap} {prev}
130+# fix the files git names, then:
131+git add <files>
132+git rebase --continue # repeat until the rebase is done
133+git push --force origin multigram-next
134+```
135+
136+To leave a MultiGram commit out instead (Forkgram now does the same thing, say), run `git rebase --skip` at that commit.
137+
138+{use_next_step()}
139+"""
140+
141+
142+def body_compile():
143+ snap, cand = sha('SYNC_SNAPSHOT') or '<snapshot>', sha('SYNC_CANDIDATE')
144+ return f"""The MultiGram stack applied cleanly to **Forkgram {version()}**, but the compile check failed. `multigram` is unchanged; the candidate is on `multigram-next` (`{short(cand)}`).
145+
146+{run_line()}
147+
148+If the log shows a runner or network hiccup, use **Re-run failed jobs** on the run page. Otherwise:
149+
150+### Fix it locally
151+
152+```sh
153+git fetch origin
154+git switch -C multigram-next origin/multigram-next
155+# fix the build, then fold each fix into the MultiGram commit it belongs to,
156+# one fixup per commit, naming that commit's files (PATCH.md 6.3 on the docs branch):
157+git commit --fixup=<that commit> -- <its files>
158+GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash {snap}
159+git push --force origin multigram-next
160+```
161+
162+{use_next_step()}
163+"""
164+
165+
166+def body_promote():
167+ cand = sha('SYNC_CANDIDATE')
168+ return f"""The candidate for **Forkgram {version()}** compiled, but moving `multigram` to it failed, usually because someone pushed to `multigram` or `multigram-next` while the sync ran. `multigram` is unchanged; the candidate is still on `multigram-next` (`{short(cand)}`).
169+
170+{run_line()}
171+
172+Check that `multigram-next` holds everything `multigram` has now (rebase any new commits onto it and push it), then run **Actions > Sync with Forkgram** with **use_next**.
173+"""
174+
175+
176+def body_error():
177+ return f"""The sync stopped with an error. Nothing was pushed after the error; `multigram` is unchanged.
178+
179+{block(env('SYNC_ERROR') or env('SYNC_MESSAGE') or 'The sync job failed before it could say why; see the run log.')}
180+{run_line()}
181+
182+Fix the cause (the message says what it is), then run **Actions > Sync with Forkgram** again.
183+"""
184+
185+
186+def body_next_edited():
187+ nxt, tip, fc = sha('SYNC_EXISTING_NEXT'), sha('SYNC_FORKGRAM_TIP'), sha('SYNC_FORKGRAM_COMMIT')
188+ if tip and tip != fc:
189+ what = f"Forkgram's `dev` has moved on (`{short(tip)}`), but the sync did not take it"
190+ else:
191+ what = 'The sync was asked to rebuild `multigram-next` (force), but did not'
192+ return f"""{what}: `multigram-next` (`{short(nxt)}`) has commits the sync did not make, most likely a fix by hand, and the sync never replaces those. Nothing was pushed; `forkgram` and `multigram` are unchanged.
193+
194+{run_line()}
195+
196+- If `multigram-next` is ready, run **Actions > Sync with Forkgram > Run workflow** with **use_next**: it compiles `multigram-next` and, if that passes, moves `multigram` to it. The next scheduled sync then takes the new Forkgram commit.
197+- If you do not need it any more, delete it (`git push origin --delete multigram-next`); the next sync rebuilds it from `multigram`.
198+"""
199+
200+
201+def body_behind():
202+ return f"""`forkgram` has **Forkgram {version()}** (snapshot `{short(sha('SYNC_SNAPSHOT'))}`), but `multigram` is still based on the older snapshot `{short(sha('SYNC_MULTIGRAM_BASE'))}`: an earlier sync could not move it and no issue is open about it.
203+
204+{run_line()}
205+
206+Run **Actions > Sync with Forkgram** with **force** to try again; if the stack still does not apply, this issue gets the details and the commands to fix it.
207+"""
208+
209+
210+def report(key, body):
211+ number = open_issue()
212+ text = marker(key) + '\n' + body
213+ if number is None:
214+ gh('label', 'create', LABEL, '--color', 'D93F0B', '--force',
215+ '--description', 'The automatic sync with Forkgram needs a hand')
216+ print(gh_body('issue', 'create', '--title', title(), '--label', LABEL, body=text).strip())
217+ return
218+ if key and already_reported(number, marker(key)):
219+ print(f'issue #{number} already reports this; nothing to add')
220+ return
221+ gh('issue', 'edit', str(number), '--title', title())
222+ gh_body('issue', 'comment', str(number), body=text)
223+ print(f'commented on issue #{number}')
224+
225+
226+def close(note):
227+ for number in open_issues():
228+ gh('issue', 'close', str(number), '--reason', 'completed', '--comment', note)
229+ print(f'closed issue #{number}')
230+
231+
232+def main():
233+ results = [env('SYNC_RESULT'), env('COMPILE_RESULT'), env('PROMOTE_RESULT')]
234+ status = env('SYNC_STATUS')
235+ print(f'sync={results[0]} compile={results[1]} promote={results[2]} status={status or "-"}')
236+ if 'cancelled' in results:
237+ print('a job was cancelled; nothing to report')
238+ return
239+ fc = sha('SYNC_FORKGRAM_COMMIT')
240+ cand = sha('SYNC_CANDIDATE')
241+ if status == 'conflict':
242+ report(f'conflict {fc} {sha("SYNC_CONFLICT_COMMIT")}', body_conflict())
243+ elif results[0] != 'success':
244+ err = env('SYNC_ERROR') or 'unknown'
245+ report('error ' + hashlib.sha1(err.encode()).hexdigest()[:12], body_error())
246+ elif status == 'next-edited':
247+ report(f'next-edited {sha("SYNC_EXISTING_NEXT")} {sha("SYNC_FORKGRAM_TIP")}', body_next_edited())
248+ elif status == 'candidate' and results[1] == 'failure':
249+ report(f'compile {cand}', body_compile())
250+ elif status == 'candidate' and results[1] == 'success' and results[2] == 'failure':
251+ report(f'promote {cand}', body_promote())
252+ elif status == 'candidate' and results[2] == 'success':
253+ tag = env('PROMOTE_TAG')
254+ moved = f'the old tip is tagged `{tag}`' if re.match(r'^[0-9A-Za-z._-]+$', tag) else 'it already was the candidate'
255+ note = f'Done: `multigram` now carries Forkgram {version()} (`{short(cand)}`); {moved}. {run_line()}'.strip()
256+ if env('SYNC_DROPPED'):
257+ note += ('\n\nThese MultiGram commits are not on the new `multigram` (Forkgram already has '
258+ 'them, or `multigram-next` left them out with allow_drop):\n' + block(env('SYNC_DROPPED')))
259+ close(note)
260+ elif status == 'behind':
261+ if open_issue() is None:
262+ report('', body_behind())
263+ else:
264+ print('multigram is behind and an issue is already open; nothing to add')
265+ elif status == 'up-to-date':
266+ if open_issue() is not None:
267+ close(f'`multigram` is in step with Forkgram {version()} again. {run_line()}'.strip())
268+ else:
269+ print('nothing to report')
270+
271+
272+if __name__ == '__main__':
273+ main()