Skip to content

Build the APK on GitHub, and survive a Telegram update - #3

Merged
2B-4G10 merged 5 commits into
mainfrom
claude/publish-earlier-changes-8rlgkq
Sep 24, 2026
Merged

2B-4G10 merged 5 commits into
mainfrom
claude/publish-earlier-changes-8rlgkq

Conversation

@2B-4G10

@2B-4G10 2B-4G10 commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

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.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 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 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 (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.

findMethodExact demanded 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

findAndHookMethod falls back to XReflect#findMethodCompatibleIfExists when 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: 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 present.

Refuse to guess — this is the actual design

A candidate is taken only when it is the single possibility:

Call site Rule Why
Passes parameter types Arity must still match Keeps the argument positions the callback indexes aligned
Passes none Any unique signature is accepted It was written against a no-arg method, so it provably cannot read param.args
Anything ambiguous Refused —

Attaching a privacy feature like HideSeen to 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

HookHealth reports how the hooks landed, as one line at startup:

hook health: 68 resolved, 2 drifted, 1 missing
  drifted (signature changed, hooked anyway): SharedConfig#setNewAppVersionAvailable
  missing methods (feature inactive): ChatActivity#processSentMessage

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

XReflectDriftTest pins 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. XReflect is 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. A pull_request run checks out a synthetic merge of head into base, so git rev-parse HEAD named 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, with pipefail so a parser error stays fatal.
  • d292414 — the job had no timeout. A run wedged on testDebugUnitTest for 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:

  • A renamed method or class cannot be found. Nothing identifies the replacement; that feature stays dead until the name is updated by hand.
  • Ambiguous or reordered signatures are left alone on purpose, per the table above.
  • The obfuscated forks (Nekogram, Cherrygram) still need their R8 mapping tables regenerated from the target APK per client release. None of this helps there.

Both limits are written into the README under "Surviving Telegram updates".

Test plan

  • The project compiles. assembleDebug and assembleRelease both green in CI. generateDexHolder embedded a 2.4 MB classes.dex; zero javac errors or warnings.
  • XReflectDriftTest runs in CI, proven from the job log rather than inferred from a green tick: 12 run, 0 failed, 0 skipped, naming com.my.televip.reflect.XReflectDriftTest. Also 11/11 locally against junit 4.13.2.
  • Both APKs upload, named after the head commit, with the install instructions rendered on the run summary and the release APK correctly reported as unsigned (no secrets configured).
  • Not verified: runtime behaviour on a device. Nothing here has been run against a real Telegram client.
    • Startup log on a current client should read hook health: N resolved, 0 drifted, 0 missing — on a matching client the fallback should not engage at all.
    • Sanity-check that an existing feature still behaves, and that the debug APK installs and loads under LSPosed 1.10+ / Vector 2.2.

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
@2B-4G10
2B-4G10 merged commit a3d388c into main Sep 24, 2026
1 check passed
2B-4G10 added a commit that referenced this pull request Sep 25, 2026
Build the APK on GitHub, and survive a Telegram update
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants