Build the APK on GitHub, and survive a Telegram update - #3
Merged
Merged
Conversation
There was no CI at all: gradle-publish.yml only fires on release:created, so nothing built on a push or a pull request. Every recent breakage was the kind a build job catches - the unsigned APK that would not install, the missing JUnit dependencies that failed 'gradle build', the toolchain disagreeing between JDK 17 and 25. Adds a Build APK workflow on push to main, on pull requests, and on manual dispatch so anyone can get an APK without a local Android SDK. It builds debug as well as release, which is the point rather than an afterthought. The release signingConfig reads keystore.properties, and CI has no such file, so a release-only job would upload app-release-unsigned.apk - the exact artifact Android rejects with "App not installed". The debug variant is signed with the auto-generated debug key, so every run carries something installable. When the signing secrets are configured the keystore is restored first and the release APK is signed properly instead. Both variants build in one Gradle invocation: they share the configuration phase and the :settingsadapter build that generateDexHolder depends on. Install instructions are written to $GITHUB_STEP_SUMMARY, so they appear on the run's own summary page with the file names for that build, and the README gains a Download section pointing at the Actions tab. Both say to install the debug APK and why the release one may not be installable. Also drops de.robv.android.xposed:api:82 from the README build requirements. That dependency went away with the legacy API 93 path; only io.github.libxposed:api:102.0.0 is left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfCFkZp4NiGPGjTKwchmxj
Hooks address Telegram by plain name - 74 classes, 172 method names - and official Telegram is not obfuscated, so that works right up until a client release moves something. Then the hook is skipped, the app behaves normally, and the only trace is one warning in a log nobody reads until a feature is visibly gone. Two changes: bend where bending is safe, and make the rest obvious. Signature drift no longer kills a hook. findAndHookMethod now falls back to XReflect#findMethodCompatibleIfExists when the exact signature is gone, and re-matches the method by name. The fallback only runs where the hook was already dead, so nothing that resolves today changes behaviour at all. It refuses to guess, which is the point. A candidate is taken only when it is the single possibility. Where the call site passes parameter types the arity must still match, so the argument positions the callback indexes stay aligned; where it passes none, the call site was written against a no-argument method and provably cannot be reading param.args, so any signature is safe. Ambiguous cases are left alone: attaching a privacy feature like HideSeen to the wrong overload is worse than that feature being off, because it would look like it works. Constructors stay strict for the same reason. A parameter type that no longer resolves is now a wildcard rather than a failure. ClassLoad returns null for a renamed class, and resolveParameterTypes used to reject that outright and take down the whole hook even when the method itself was still there. That one position is now simply unknown. HookHealth reports how the hooks landed, as one line at startup - resolved, drifted, and the symbols not found at all - so a bug report can name the symbol that moved instead of "stories stopped working". Counts stay exact even when the printed list is capped. XReflectDriftTest pins all of it down, because the value here is the refusals: someone later "simplifying" the matcher into a looser one would be reintroducing exactly the failure mode this avoids. The tests run on the JVM, and CI now runs them before building the APK. Verified locally: 11 tests pass against junit 4.13.2. Drops findMethodExact, which had no callers left once the hook path stopped using it. A rename is still fatal to the feature that depended on it, and the obfuscated forks still need their mapping tables regenerated per build. Both limits are written down in the README. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfCFkZp4NiGPGjTKwchmxj
Two defects the workflow's own first run exposed. The artifact came out as TeleVip-3.7.0-f55e39c, and f55e39c exists nowhere in the repository. A pull_request run checks out a synthetic merge of the head into the base, so 'git rev-parse HEAD' names that merge commit rather than the commit under review - which makes an APK impossible to trace back. It is now named after the pull request's head commit, falling back to github.sha for push and manual runs. Truncating the SHA in the shell rather than asking git for it also avoids depending on an object the shallow merge-ref checkout may not have fetched. Gradle prints nothing for a test task that passes, so the run offered no evidence the tests had executed at all - and a task that finds zero tests succeeds in exactly the same silence. That is a bad property for a suite whose whole job is to stop the drift matcher being loosened. The counts and the suite names now go on the run summary, and say so explicitly when no results were produced. The HTML report uploads on failure, since that is the only way to see which case broke. Verified locally: the extracted run block passes bash -n, and the parser was executed against sample JUnit XML for both paths - reporting "12 run, 0 failed, 0 skipped" with the suite names, and the explicit did-not-run message when the results directory is empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfCFkZp4NiGPGjTKwchmxj
The previous commit sent the counts to $GITHUB_STEP_SUMMARY, which is a file. That renders on the run's summary page but leaves nothing in the job log, so the run still offered no evidence in the place anyone debugging it actually looks - the same gap the step was added to close, just moved. tee writes both. set -o pipefail keeps a parser failure fatal, which a pipeline would otherwise hide behind tee's exit status. Verified locally: the block passes bash -n, emits the counts and suite names to stdout and the summary file together, and exits non-zero when the XML cannot be parsed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfCFkZp4NiGPGjTKwchmxj
A run wedged on ./gradlew :app:testDebugUnitTest for fifteen minutes and counting, on a step that had taken 68 seconds on the commit immediately before it. The command was byte-identical across the two - only a later step in this workflow had changed - so the cause was environmental rather than anything in the build. What made it worth fixing is not the wedge itself but that nothing would have stopped it. With no timeout declared a job inherits GitHub's six-hour default, so a hang holds a runner for the rest of the afternoon and the pull request simply never reports. Thirty minutes for the job, against roughly three for a green run, leaves room for a cold cache while still failing in useful time. The tests get twelve of their own so a wedge there is reported as the tests hanging rather than as the job timing out somewhere unspecified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfCFkZp4NiGPGjTKwchmxj
This was referenced Sep 25, 2026
2B-4G10
added a commit
that referenced
this pull request
Sep 25, 2026
Build the APK on GitHub, and survive a Telegram update
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
CI that produces an installable APK, and making the hooks survive a Telegram release that moves things.
1. Build the APK on GitHub (
0d9b93e)There was no CI at all —
gradle-publish.ymlonly fires onrelease: created, so nothing built on a push or a pull request. Every recent breakage was the kind a build job catches: the unsigned APK that would not install, the missing JUnit dependencies that failedgradle build, the toolchain disagreeing between JDK 17 and 25.Adds Build APK on push to
main, on pull requests, and on manual dispatch, so anyone can get an APK without a local Android SDK.It builds debug as well as release, which is the point rather than an afterthought. The release
signingConfigreadskeystore.properties, and CI has no such file, so a release-only job would uploadapp-release-unsigned.apk— the exact artifact Android rejects with "App not installed". The debug variant is signed with the auto-generated debug key, so every run carries something installable. When the signing secrets (KEYSTORE_BASE64,KEYSTORE_PASSWORD,KEY_ALIAS,KEY_PASSWORD) are configured, the keystore is restored first and the release APK is signed properly instead.Install instructions are written to
$GITHUB_STEP_SUMMARY, so they appear on each run's own summary page with that build's file names, and the README gains a 📥 Download section pointing at the Actions tab.2. Survive a Telegram update (
c6ea417)Hooks address Telegram by plain name — 74 classes, 172 method names — and official Telegram is not obfuscated, so that works right up until a client release moves something. Then the hook is skipped, the app behaves normally, and the only trace is one warning in a log nobody reads until a feature is visibly gone.
findMethodExactdemanded an exact parameter-type match, and 52 hook sites pass no types at all, so they only matched a zero-arg method. Telegram adding one parameter killed the hook even though the method was still there.Bend where bending is safe
findAndHookMethodfalls back toXReflect#findMethodCompatibleIfExistswhen the exact signature is gone, and re-matches by name. The fallback only runs where the hook was already dead, so nothing that resolves today changes behaviour.A parameter type that no longer resolves is now a wildcard rather than a failure:
ClassLoadreturnsnullfor a renamed class, andresolveParameterTypesused to reject that outright and take down the whole hook even when the method itself was still present.Refuse to guess — this is the actual design
A candidate is taken only when it is the single possibility:
param.argsAttaching a privacy feature like
HideSeento the wrong overload is worse than that feature being off, because it would look like it works while still leaking. Constructors stay strict for the same reason.Make the rest obvious
HookHealthreports how the hooks landed, as one line at startup:A bug report can then name the symbol that moved instead of "stories stopped working". Counts stay exact even when the printed list is capped.
Tests
XReflectDriftTestpins all of it down, because the value here is the refusals — someone later "simplifying" the matcher into a looser one would reintroduce exactly the failure mode this avoids.XReflectis plain reflection with no Android dependency, so these run on the JVM, and CI runs them before building the APK.Also drops
findMethodExact, which had no callers left once the hook path stopped using it.3. Three fixes the workflow found in itself
The workflow ran against its own pull request, which exposed three defects worth keeping:
4524960— builds were named after a commit that does not exist. Apull_requestrun checks out a synthetic merge of head into base, sogit rev-parse HEADnamed that, making an APK impossible to trace. Now named after the PR head commit.03230cf— the test counts were invisible. Gradle prints nothing for a passing test task, and a task that finds zero tests succeeds in exactly the same silence — a bad property for a suite whose job is to stop the drift matcher being loosened. Counts now go to the job log as well as the summary, withpipefailso a parser error stays fatal.d292414— the job had no timeout. A run wedged ontestDebugUnitTestfor 15 minutes having taken 68 seconds on the commit before; with no timeout declared it would have held a runner for GitHub's six-hour default while the PR never reported. Now 30 minutes for the job, 12 for the tests so a wedge is named as such.What this does not do
This does not make the module work on all Telegram versions, and nothing can:
Both limits are written into the README under "Surviving Telegram updates".
Test plan
assembleDebugandassembleReleaseboth green in CI.generateDexHolderembedded a 2.4 MBclasses.dex; zero javac errors or warnings.XReflectDriftTestruns in CI, proven from the job log rather than inferred from a green tick:12 run, 0 failed, 0 skipped, namingcom.my.televip.reflect.XReflectDriftTest. Also 11/11 locally against junit 4.13.2.hook health: N resolved, 0 drifted, 0 missing— on a matching client the fallback should not engage at all.