From 0d9b93e784bb4b45b8c34a98bfe98fcd8c9bebe3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 14:51:33 +0000 Subject: [PATCH 1/5] Build the APK on GitHub and say how to install it 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 Claude-Session: https://claude.ai/code/session_01JfCFkZp4NiGPGjTKwchmxj --- .github/workflows/build-apk.yml | 150 ++++++++++++++++++++++++++++++++ README.md | 29 +++++- 2 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/build-apk.yml diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 0000000..84833b2 --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,150 @@ +name: Build APK + +# Builds an installable APK straight from GitHub, so no local Android SDK is needed. +# Run it by hand from the Actions tab (workflow_dispatch), or let it run on every push to +# main and on pull requests to catch a broken build before it lands. +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + build: + name: Build APK + runs-on: ubuntu-latest + + # Secrets cannot be read from a step-level 'if', so the one the steps branch on is mapped + # to an env var here and tested as env.KEYSTORE_BASE64 below. + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + + steps: + - name: Check out the repository + uses: actions/checkout@v4 + + # Temurin 17 matches the Java toolchain both modules request. Without a matching JDK + # present the foojay resolver in settings.gradle would download one on every run. + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + # compileSdk is 36, which the runner image does not always ship. Installing it + # explicitly also accepts the SDK licences, which AGP needs before it will build. + - name: Set up the Android SDK + uses: android-actions/setup-android@v3 + with: + packages: 'platforms;android-36 build-tools;36.0.0' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + # The release signingConfig reads keystore.properties from the repository root. When the + # signing secrets are configured this recreates it so the release APK comes out signed + # and installable. Without them the step is skipped and the release APK stays unsigned, + # which is why the debug APK is built too. + - name: Restore the signing keystore + if: env.KEYSTORE_BASE64 != '' + env: + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: | + echo "$KEYSTORE_BASE64" | base64 --decode > release.jks + { + echo "storeFile=release.jks" + echo "storePassword=$KEYSTORE_PASSWORD" + echo "keyAlias=$KEY_ALIAS" + echo "keyPassword=$KEY_PASSWORD" + } > keystore.properties + + # Debug is always built because it is signed with the auto-generated debug key, so every + # run has one APK that can actually be installed. An unsigned release APK cannot be - + # Android rejects it with "App not installed". Both variants go in one invocation so the + # configuration phase and the :settingsadapter build they share only happen once. + - name: Build the APKs + run: ./gradlew :app:assembleDebug :app:assembleRelease --stacktrace + + - name: Collect the APKs + id: collect + run: | + mkdir -p artifacts + version=$(grep -m1 'versionName' app/build.gradle | cut -d'"' -f2) + short_sha=$(git rev-parse --short HEAD) + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT" + + cp app/build/outputs/apk/debug/app-debug.apk \ + "artifacts/TeleVip-$version-$short_sha-debug.apk" + + # Named app-release.apk when signed and app-release-unsigned.apk when not. + release_apk=$(find app/build/outputs/apk/release -name '*.apk' | head -n 1) + case "$release_apk" in + *unsigned*) suffix="release-unsigned" ;; + *) suffix="release" ;; + esac + cp "$release_apk" "artifacts/TeleVip-$version-$short_sha-$suffix.apk" + echo "release_suffix=$suffix" >> "$GITHUB_OUTPUT" + + ls -lh artifacts + + - name: Upload the APKs + uses: actions/upload-artifact@v4 + with: + name: TeleVip-${{ steps.collect.outputs.version }}-${{ steps.collect.outputs.short_sha }} + path: artifacts/*.apk + if-no-files-found: error + retention-days: 90 + + # Rendered on the run's own page, so whoever triggered the build sees how to install the + # result without digging through the repository. + - name: Write the install instructions + run: | + artifact="TeleVip-${{ steps.collect.outputs.version }}-${{ steps.collect.outputs.short_sha }}" + release_apk="...-${{ steps.collect.outputs.release_suffix }}.apk" + + if [ "${{ steps.collect.outputs.release_suffix }}" = "release" ]; then + release_note="**Yes** - signed with the repository key" + else + release_note="No - unsigned, see below" + fi + + cat >> "$GITHUB_STEP_SUMMARY" < Secrets and variables -> Actions**. + EOF diff --git a/README.md b/README.md index 112c7cd..bd9d0b8 100755 --- a/README.md +++ b/README.md @@ -96,15 +96,38 @@ path, which is what used to break language loading on app-scoped modules under Z +# 📥 Download + +GitHub builds the APK for you, so you do not need an Android SDK to get one. + +1. Open the [**Actions** tab](../../actions/workflows/build-apk.yml). +2. Click the newest **Build APK** run — or press **Run workflow** to build the current code now. +3. Scroll to **Artifacts**, download the `TeleVip-…` file, and unzip it. +4. Copy `TeleVip-…-debug.apk` to your phone and open it to install. +5. In **LSPosed** or **Vector**: **Modules** → enable **TeleVip** → tick your Telegram clients → + **force stop** Telegram and reopen it. + +Each run repeats these steps on its own summary page, with the exact file names for that build. + +> Install the **debug** APK. The release APK is only signed when the repository has signing +> secrets set (`KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`) — Android +> refuses to install an unsigned APK, which is what "App not installed" means. + + + # 🛠️ Building ```bash ./gradlew :app:assembleRelease ``` -Requirements: JDK 17, Android SDK 36. The two Xposed APIs are `compileOnly` dependencies -(`de.robv.android.xposed:api:82` and `io.github.libxposed:api:102.0.0`), so neither is packaged — -the framework provides its own implementation at runtime. +Requirements: JDK 17, Android SDK 36. The libxposed API (`io.github.libxposed:api:102.0.0`) is a +`compileOnly` dependency, so it is not packaged — the framework provides its own implementation at +runtime. + +To get an installable APK locally, either build the debug variant (`./gradlew :app:assembleDebug`, +signed with the debug key) or create a `keystore.properties` in the repository root so the release +variant is signed. The `Nekogram` and `Cherrygram` resolvers hold R8 name mappings that are **specific to one client build**. When a mismatch is detected TeleVip now logs a single explicit warning at startup instead From c6ea4172db7576b1eb01f047fb0486afc9b66f3a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 13:38:55 +0000 Subject: [PATCH 2/5] Survive a Telegram update instead of silently losing a feature 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 Claude-Session: https://claude.ai/code/session_01JfCFkZp4NiGPGjTKwchmxj --- .github/workflows/build-apk.yml | 5 + CHANGELOG.md | 30 ++++ README.md | 39 ++++++ .../java/com/my/televip/Class/ClassLoad.java | 3 + app/src/main/java/com/my/televip/TeleVip.java | 5 + .../my/televip/diagnostics/HookHealth.java | 123 +++++++++++++++++ .../java/com/my/televip/reflect/XReflect.java | 108 +++++++++++++-- .../java/com/my/televip/xposed/XBridge.java | 19 ++- .../my/televip/reflect/XReflectDriftTest.java | 129 ++++++++++++++++++ 9 files changed, 450 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/my/televip/diagnostics/HookHealth.java create mode 100644 app/src/test/java/com/my/televip/reflect/XReflectDriftTest.java diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 84833b2..8271cd5 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -63,6 +63,11 @@ jobs: echo "keyPassword=$KEY_PASSWORD" } > keystore.properties + # The drift-tolerance rules in XReflect decide how far a hook may bend when Telegram moves + # something, so a regression there is worth failing the build before an APK is produced. + - name: Run the unit tests + run: ./gradlew :app:testDebugUnitTest --stacktrace + # Debug is always built because it is signed with the auto-generated debug key, so every # run has one APK that can actually be installed. An unsigned release APK cannot be - # Android rejects it with "App not installed". Both variants go in one invocation so the diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e8181f..e46499a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## Unreleased — surviving Telegram updates + +Hooks address Telegram by plain name across 74 classes and 172 method names, so a client release +that moves one of them silently takes a feature out. Two changes make that less likely and, when +it does happen, obvious. + +- **Signature drift no longer kills a hook.** `XBridge#findAndHookMethod` falls back to + `XReflect#findMethodCompatibleIfExists` when the exact signature is gone: the method is + re-matched by name and hooked anyway. The fallback only runs where the hook was already dead, so + nothing that resolves today changes behaviour. + + It accepts a candidate only when it is unambiguous. With parameter types given, the arity must + match so the argument positions a callback indexes stay aligned; with none given, the call site + was written against a no-argument method and provably cannot be reading arguments. Anything else + is refused — attaching a privacy feature to the wrong overload would be worse than leaving it + off, because it would look like it works. Constructors stay strict for the same reason. + +- **A parameter type that no longer resolves is a wildcard.** `ClassLoad` returns `null` for a + renamed class, and that `null` used to fail the whole hook in `resolveParameterTypes`. It now + marks that one position as unknown, so a renamed inner class no longer takes down a hook whose + method is still present. + +- **`HookHealth` reports how the hooks landed**, as one line at startup: how many resolved, how + many were recovered after drifting, and which symbols were not found at all. Counts stay exact + even when the printed list is capped. Previously this was one warning per failure, scattered + through a log nobody reads until something is visibly broken. + +A rename is still fatal to the feature that depended on it, and the obfuscated forks still need +their mapping tables regenerated per client build. See "Surviving Telegram updates" in the README. + ## 3.7.0 — Xposed API 102, Vector 2.2, Zygisk Next, Nekogram X ### Modern Xposed API (libxposed 102) diff --git a/README.md b/README.md index bd9d0b8..dccabdf 100755 --- a/README.md +++ b/README.md @@ -96,6 +96,45 @@ path, which is what used to break language loading on app-scoped modules under Z +# 🔄 Surviving Telegram updates + +Official Telegram is not obfuscated, so TeleVip hooks it by plain name — 74 classes and 172 +distinct method names. Every one of those is something a client release can move, and when one +moves the matching feature simply stops working. + +What the module does about it: + +- **Signature drift is tolerated.** A method that keeps its name but gains a parameter, or has a + parameter type renamed, used to take its hook down. It is now re-matched by name, and the hook + is attached anyway. This is only attempted once the exact lookup has already failed, so a hook + that still resolves normally behaves exactly as before. +- **It refuses to guess.** A drifted candidate is accepted 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 reads stay aligned; where it passes none, the callback was written against a + no-argument method and cannot be reading arguments at all. Anything ambiguous is left alone. +- **A class that no longer resolves becomes a wildcard** rather than failing the whole hook, so a + renamed inner class no longer takes down a hook whose method is still there. +- **Breakage is visible.** One line at startup reports how the hooks landed: + + ``` + 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". + +What it still cannot do, and no amount of matching will: + +- **A renamed method or class cannot be found.** If Telegram renames `allowScreenshots`, nothing + identifies the replacement; that feature is inactive until the name is updated here. +- **A reordered or ambiguous signature is left alone** on purpose — attaching to the wrong overload + in a privacy feature is worse than that feature being off, because it would look like it works. +- **The obfuscated forks** (Nekogram, Cherrygram) still need their R8 mapping tables regenerated + from the target APK on every client release. None of the above helps there. + + + # 📥 Download GitHub builds the APK for you, so you do not need an Android SDK to get one. diff --git a/app/src/main/java/com/my/televip/Class/ClassLoad.java b/app/src/main/java/com/my/televip/Class/ClassLoad.java index 5e7350e..157821b 100755 --- a/app/src/main/java/com/my/televip/Class/ClassLoad.java +++ b/app/src/main/java/com/my/televip/Class/ClassLoad.java @@ -1,6 +1,7 @@ package com.my.televip.Class; import com.my.televip.ClientChecker; +import com.my.televip.diagnostics.HookHealth; import com.my.televip.logging.Logger; import com.my.televip.obfuscate.AutomationResolver; import com.my.televip.utils.Utils; @@ -30,6 +31,7 @@ public static Class getClass(String name) { cache.put(resolved, cls); } else { if ((ClientChecker.check(ClientChecker.ClientType.Nagram) || ClientChecker.check(ClientChecker.ClientType.Momogram)) && name.equals(ClassNames.DRAWABLE)) return null; + HookHealth.missingClass(resolved); Logger.w("Not found " + name + ", " + resolved + " " + Utils.issue); } return cls; @@ -55,6 +57,7 @@ public static Class getClass(String name, ClassLoader classLoader) { if (cls != null) { cache.put(resolved, cls); } else { + HookHealth.missingClass(resolved); Logger.w("Not found Class " + name + ", " + resolved + " " + Utils.issue); } return cls; diff --git a/app/src/main/java/com/my/televip/TeleVip.java b/app/src/main/java/com/my/televip/TeleVip.java index c784753..515bd46 100644 --- a/app/src/main/java/com/my/televip/TeleVip.java +++ b/app/src/main/java/com/my/televip/TeleVip.java @@ -6,6 +6,7 @@ import com.my.televip.Configs.ConfigManager; import com.my.televip.application.AndroidUtilities; +import com.my.televip.diagnostics.HookHealth; import com.my.televip.dex.DexInjector; import com.my.televip.language.Translator; import com.my.televip.logging.Logger; @@ -29,6 +30,10 @@ public static void startHook(Context context) { ConfigManager.loadAndRead(context); SettingsManager.init(settingsController); + // Everything the enabled features hook is installed by now, so one line can say + // whether this client release still looks the way the hooks expect. + HookHealth.logReport(); + } catch (Throwable e){ Logger.e(e); } diff --git a/app/src/main/java/com/my/televip/diagnostics/HookHealth.java b/app/src/main/java/com/my/televip/diagnostics/HookHealth.java new file mode 100644 index 0000000..139780d --- /dev/null +++ b/app/src/main/java/com/my/televip/diagnostics/HookHealth.java @@ -0,0 +1,123 @@ +package com.my.televip.diagnostics; + +import com.my.televip.logging.Logger; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Records how each hook landed so a Telegram update that moves things is visible at a glance. + * + *

Hooks address Telegram by plain name, so a client release that renames or reshapes something + * takes the matching feature out silently: the hook is skipped, the app behaves normally, and the + * only evidence is one warning buried in a log that nobody reads until something is obviously + * wrong. That is the real cost of a Telegram update — not that a feature breaks, but that nobody + * can tell which one.

+ * + *

The counters below turn that into a single line at startup: how many hooks resolved, how many + * were recovered after their signature drifted, and exactly which ones could not be found at all. + * A bug report can then name the broken symbol instead of "stories stopped working".

+ */ +public final class HookHealth { + + private HookHealth() { + } + + /** Keeps the report readable, and bounds what a pathological mismatch can accumulate. */ + private static final int MAX_LISTED = 40; + + private static final AtomicInteger resolvedCount = new AtomicInteger(); + private static final AtomicInteger driftedCount = new AtomicInteger(); + private static final AtomicInteger missingCount = new AtomicInteger(); + + // The counters above stay exact; these only hold the names the report has room to print, so + // a client that moved hundreds of symbols still reports how many rather than how many fitted. + private static final Set drifted = + Collections.synchronizedSet(new LinkedHashSet()); + private static final Set missingMembers = + Collections.synchronizedSet(new LinkedHashSet()); + private static final Set missingClasses = + Collections.synchronizedSet(new LinkedHashSet()); + + /** A hook resolved exactly as the call site described it. */ + public static void resolved() { + resolvedCount.incrementAndGet(); + } + + /** A hook resolved only after its signature was allowed to differ. */ + public static void drifted(String symbol) { + if (symbol == null) return; + driftedCount.incrementAndGet(); + add(drifted, symbol); + } + + /** A method or constructor the call site asked for does not exist any more. */ + public static void missingMember(String symbol) { + if (symbol == null) return; + missingCount.incrementAndGet(); + add(missingMembers, symbol); + } + + /** A class name no longer resolves in the client. */ + public static void missingClass(String name) { + if (name == null) return; + missingCount.incrementAndGet(); + add(missingClasses, name); + } + + private static void add(Set target, String value) { + if (target.size() >= MAX_LISTED) return; + target.add(value); + } + + public static String report() { + StringBuilder summary = new StringBuilder("hook health: ") + .append(resolvedCount.get()).append(" resolved, ") + .append(driftedCount.get()).append(" drifted, ") + .append(missingCount.get()).append(" missing"); + + append(summary, "drifted (signature changed, hooked anyway)", drifted); + append(summary, "missing methods (feature inactive)", missingMembers); + append(summary, "missing classes (feature inactive)", missingClasses); + return summary.toString(); + } + + private static void append(StringBuilder summary, String label, Set values) { + if (values.isEmpty()) return; + summary.append("\n ").append(label).append(": "); + synchronized (values) { + summary.append(join(values)); + if (values.size() >= MAX_LISTED) summary.append(", ..."); + } + } + + private static String join(Collection values) { + StringBuilder joined = new StringBuilder(); + for (String value : values) { + if (joined.length() > 0) joined.append(", "); + joined.append(value); + } + return joined.toString(); + } + + /** True when something the module wanted was not where it expected it. */ + public static boolean hasDrift() { + return driftedCount.get() > 0 || missingCount.get() > 0; + } + + /** + * Logs the report once startup has installed its hooks. Anything hooked later — the chat and + * profile screens are wired on first use — still updates the counters and still logs its own + * warning, it just lands after this summary. + */ + public static void logReport() { + if (hasDrift()) { + Logger.w(report()); + } else { + Logger.l(report()); + } + } +} diff --git a/app/src/main/java/com/my/televip/reflect/XReflect.java b/app/src/main/java/com/my/televip/reflect/XReflect.java index 34897ce..458b4df 100644 --- a/app/src/main/java/com/my/televip/reflect/XReflect.java +++ b/app/src/main/java/com/my/televip/reflect/XReflect.java @@ -4,8 +4,13 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** @@ -77,10 +82,14 @@ public static Class[] resolveParameterTypes(ClassLoader classLoader, Object.. Class[] types = new Class[specs == null ? 0 : specs.length]; for (int i = 0; i < types.length; i++) { Object spec = specs[i]; - if (spec instanceof Class) { + if (spec == null || spec instanceof Class) { + // A null spec means the call site resolved this parameter type against the client + // and came back empty, so the class has been renamed or removed. Keeping the + // position null marks it as a wildcard for findMethodCompatibleIfExists instead of + // failing the whole hook here, which is all that used to happen. types[i] = (Class) spec; } else if (spec instanceof String) { - types[i] = findClass((String) spec, classLoader); + types[i] = findClassIfExists((String) spec, classLoader); } else { throw new IllegalArgumentException( "Parameter type must be a Class or a class name, got: " + spec); @@ -91,16 +100,11 @@ public static Class[] resolveParameterTypes(ClassLoader classLoader, Object.. // ---------------------------------------------------------------- methods - public static Method findMethodExact(Class clazz, String name, Class... parameterTypes) { - Method method = findMethodExactIfExists(clazz, name, parameterTypes); - if (method == null) { - throw new NoSuchMethodError(descriptor(clazz, name, parameterTypes)); - } - return method; - } - public static Method findMethodExactIfExists(Class clazz, String name, Class... parameterTypes) { if (clazz == null || name == null) return null; + // A null entry means the call site asked for a parameter type that no longer resolves. + // getDeclaredMethod cannot match that, so skip straight to the tolerant lookup. + if (hasUnresolvedType(parameterTypes)) return null; for (Class current = clazz; current != null; current = current.getSuperclass()) { try { Method method = current.getDeclaredMethod(name, parameterTypes); @@ -113,6 +117,90 @@ public static Method findMethodExactIfExists(Class clazz, String name, Class< return null; } + /** + * Finds a method whose signature has drifted since the call site was written. + * + *

Telegram is not obfuscated, so hooks address it by plain name — but a method keeping its + * name while gaining a parameter, or having a parameter type renamed, is routine across client + * releases and defeats the exact lookup above. This is the fallback for that case, and it is + * only ever consulted once the exact match has already failed, so it cannot change the + * behaviour of a hook that still resolves normally.

+ * + *

It refuses to guess. A candidate is accepted only when it is the single possibility:

+ *
    + *
  • No parameters requested — the call site was written against a no-argument method, so + * its callback cannot be reading {@code param.args} and any signature is safe to take. + * Accepted only when exactly one method carries the name.
  • + *
  • Parameters requested — the arity must still match, so the argument positions the + * callback indexes stay aligned. Types are compared where the call site could still + * resolve them; a {@code null} entry is treated as a wildcard. Accepted only when + * exactly one candidate fits.
  • + *
+ * + * @return the drifted method, or {@code null} when there is no unambiguous answer + */ + public static Method findMethodCompatibleIfExists(Class clazz, String name, + Class... parameterTypes) { + if (clazz == null || name == null) return null; + + List named = findMethodsNamed(clazz, name); + if (named.isEmpty()) return null; + + int requested = parameterTypes == null ? 0 : parameterTypes.length; + if (requested == 0) { + return named.size() == 1 ? accessible(named.get(0)) : null; + } + + Method match = null; + for (Method candidate : named) { + Class[] actual = candidate.getParameterTypes(); + if (actual.length != requested) continue; + boolean fits = true; + for (int i = 0; i < requested; i++) { + Class wanted = parameterTypes[i]; + if (wanted != null && !wanted.equals(actual[i])) { + fits = false; + break; + } + } + if (!fits) continue; + if (match != null) return null; // more than one fits, so any pick would be a guess + match = candidate; + } + return match == null ? null : accessible(match); + } + + /** + * Every method with this name in the hierarchy, most-derived first, one entry per signature so + * an override does not read as a second candidate. + */ + private static List findMethodsNamed(Class clazz, String name) { + List found = new ArrayList<>(); + Set seen = new HashSet<>(); + for (Class current = clazz; current != null; current = current.getSuperclass()) { + for (Method method : current.getDeclaredMethods()) { + if (!method.getName().equals(name)) continue; + if (seen.add(Arrays.toString(method.getParameterTypes()))) { + found.add(method); + } + } + } + return found; + } + + private static boolean hasUnresolvedType(Class[] parameterTypes) { + if (parameterTypes == null) return false; + for (Class parameterType : parameterTypes) { + if (parameterType == null) return true; + } + return false; + } + + private static Method accessible(Method method) { + method.setAccessible(true); + return method; + } + /** * Picks the most specific method callable with {@code args}. Mirrors the legacy * {@code findMethodBestMatch} semantics: exact runtime types win, then widening / boxing / diff --git a/app/src/main/java/com/my/televip/xposed/XBridge.java b/app/src/main/java/com/my/televip/xposed/XBridge.java index 4d2e56d..8fc373c 100644 --- a/app/src/main/java/com/my/televip/xposed/XBridge.java +++ b/app/src/main/java/com/my/televip/xposed/XBridge.java @@ -3,6 +3,7 @@ import android.util.Log; import com.my.televip.base.AbstractMethodHook; +import com.my.televip.diagnostics.HookHealth; import com.my.televip.reflect.XReflect; import java.lang.reflect.Constructor; @@ -184,7 +185,23 @@ public static void findAndHookMethod(Class clazz, String methodName, AbstractMethodHook callback = takeCallback(parameterTypesAndCallback); Class[] parameterTypes = XReflect.resolveParameterTypes( clazz.getClassLoader(), dropLast(parameterTypesAndCallback)); - Method method = XReflect.findMethodExact(clazz, methodName, parameterTypes); + + Method method = XReflect.findMethodExactIfExists(clazz, methodName, parameterTypes); + if (method != null) { + HookHealth.resolved(); + } else { + // The exact signature is gone. Telegram renames and reshapes freely between releases, + // so before giving the feature up entirely, see whether the method is still there + // under the same name with a signature the call site can safely be attached to. + method = XReflect.findMethodCompatibleIfExists(clazz, methodName, parameterTypes); + String symbol = clazz.getSimpleName() + "#" + methodName; + if (method == null) { + HookHealth.missingMember(symbol); + throw new NoSuchMethodError(symbol + " not found in " + clazz.getName()); + } + HookHealth.drifted(symbol); + log("[TeleVip] signature drift, hooking anyway: " + symbol + " is now " + method); + } hook(method, callback); } diff --git a/app/src/test/java/com/my/televip/reflect/XReflectDriftTest.java b/app/src/test/java/com/my/televip/reflect/XReflectDriftTest.java new file mode 100644 index 0000000..d5b945b --- /dev/null +++ b/app/src/test/java/com/my/televip/reflect/XReflectDriftTest.java @@ -0,0 +1,129 @@ +package com.my.televip.reflect; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +import java.lang.reflect.Method; + +/** + * Locks down how far a hook is allowed to bend when Telegram moves something. + * + *

These rules are a safety boundary, not a convenience: attaching a privacy feature to the + * wrong overload is worse than leaving that feature off, because it looks like it works. Loosening + * anything here should be a deliberate decision, so each rule has a test.

+ * + *

{@link XReflect} is plain reflection with no Android dependency, so this runs on the JVM.

+ */ +public class XReflectDriftTest { + + /** The shape a call site was written against. */ + static class Original { + public boolean allowScreenshots() { + return true; + } + } + + /** The same method, one client release later, having gained a parameter. */ + static class GrewAParameter { + public boolean allowScreenshots(boolean forced) { + return true; + } + } + + static class Overloaded { + public void foo(int a) { + } + + public void foo(String a) { + } + } + + static class OneParameter { + public void setListener(Runnable listener) { + } + } + + static class Parent { + public void shared() { + } + } + + static class Child extends Parent { + @Override + public void shared() { + } + } + + // ------------------------------------------------------------ exact first + + @Test + public void exactMatchStillWins() { + assertNotNull(XReflect.findMethodExactIfExists(Original.class, "allowScreenshots")); + } + + @Test + public void exactMatchFailsOnceTheSignatureGrew() { + assertNull(XReflect.findMethodExactIfExists(GrewAParameter.class, "allowScreenshots")); + } + + // ------------------------------------------------------- tolerated drift + + @Test + public void aNoArgumentCallSiteAcceptsAnyUniqueSignature() { + // The call site asked for no parameters, so its callback cannot be reading param.args. + Method method = + XReflect.findMethodCompatibleIfExists(GrewAParameter.class, "allowScreenshots"); + assertNotNull(method); + assertEquals(1, method.getParameterTypes().length); + } + + @Test + public void anUnresolvableParameterTypeIsAWildcard() { + // ClassLoad returns null for a class the client renamed; arity still has to match. + assertNotNull(XReflect.findMethodCompatibleIfExists( + OneParameter.class, "setListener", new Class[]{null})); + } + + @Test + public void anInheritedMethodIsFound() { + assertNotNull(XReflect.findMethodExactIfExists(Child.class, "shared")); + } + + @Test + public void anOverrideIsNotCountedAsASecondCandidate() { + assertNotNull(XReflect.findMethodCompatibleIfExists(Child.class, "shared")); + } + + // ----------------------------------------------------------- refused guesses + + @Test + public void ambiguousOverloadsAreRefused() { + assertNull(XReflect.findMethodCompatibleIfExists(Overloaded.class, "foo")); + } + + @Test + public void twoCandidatesOfTheRequestedArityAreRefused() { + assertNull(XReflect.findMethodCompatibleIfExists( + Overloaded.class, "foo", new Class[]{null})); + } + + @Test + public void arityIsEnforcedSoArgumentPositionsStayAligned() { + assertNull(XReflect.findMethodCompatibleIfExists( + OneParameter.class, "setListener", new Class[]{null, null})); + } + + @Test + public void aTypeThatIsKnownAndWrongIsRefused() { + assertNull(XReflect.findMethodCompatibleIfExists( + OneParameter.class, "setListener", new Class[]{String.class})); + } + + @Test + public void aNameThatDoesNotExistIsNotInvented() { + assertNull(XReflect.findMethodCompatibleIfExists(Original.class, "gone")); + } +} From 452496096ae833a448a26445b886fbf0b5c3e40c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 14:01:58 +0000 Subject: [PATCH 3/5] Name builds after a real commit, and show the test counts 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 Claude-Session: https://claude.ai/code/session_01JfCFkZp4NiGPGjTKwchmxj --- .github/workflows/build-apk.yml | 43 ++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 8271cd5..5958bf3 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -68,6 +68,44 @@ jobs: - name: Run the unit tests run: ./gradlew :app:testDebugUnitTest --stacktrace + # Gradle says nothing on success, so a run that executed no tests at all looks exactly like + # a run that passed. Put the counts on the summary page where they can be read at a glance. + - name: Summarise the unit tests + if: always() + run: | + python3 - >> "$GITHUB_STEP_SUMMARY" <<'PY' + import glob, xml.etree.ElementTree as ET + + reports = sorted(glob.glob('app/build/test-results/testDebugUnitTest/*.xml')) + if not reports: + print("\n### Unit tests\n\nNo results were produced — the tests did not run.") + raise SystemExit(0) + + total = failed = skipped = 0 + suites = [] + for report in reports: + root = ET.parse(report).getroot() + total += int(root.get('tests', 0)) + failed += int(root.get('failures', 0)) + int(root.get('errors', 0)) + skipped += int(root.get('skipped', 0)) + suites.append(root.get('name')) + + print("\n### Unit tests\n") + print(f"{total} run, {failed} failed, {skipped} skipped.\n") + for suite in suites: + print(f"- `{suite}`") + PY + + # The report is the only way to see which case broke once a test does fail. + - name: Upload the test report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: unit-test-report + path: app/build/reports/tests/testDebugUnitTest + if-no-files-found: ignore + retention-days: 14 + # Debug is always built because it is signed with the auto-generated debug key, so every # run has one APK that can actually be installed. An unsigned release APK cannot be - # Android rejects it with "App not installed". Both variants go in one invocation so the @@ -80,7 +118,10 @@ jobs: run: | mkdir -p artifacts version=$(grep -m1 'versionName' app/build.gradle | cut -d'"' -f2) - short_sha=$(git rev-parse --short HEAD) + # On a pull_request run the checkout is a synthetic merge commit that exists nowhere in + # the repository, so name the build after the commit someone can actually look up. + sha="${{ github.event.pull_request.head.sha || github.sha }}" + short_sha="${sha:0:7}" echo "version=$version" >> "$GITHUB_OUTPUT" echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT" From 03230cfc72e766774882b529279ce54e824a15f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 14:05:32 +0000 Subject: [PATCH 4/5] Put the test counts in the job log, not only the summary 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 Claude-Session: https://claude.ai/code/session_01JfCFkZp4NiGPGjTKwchmxj --- .github/workflows/build-apk.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 5958bf3..3b2a01a 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -73,7 +73,10 @@ jobs: - name: Summarise the unit tests if: always() run: | - python3 - >> "$GITHUB_STEP_SUMMARY" <<'PY' + # tee, not a plain redirect: the summary is a file, so counts written only there are + # invisible in the job log, which is where anyone debugging the run actually looks. + set -o pipefail + python3 - <<'PY' | tee -a "$GITHUB_STEP_SUMMARY" import glob, xml.etree.ElementTree as ET reports = sorted(glob.glob('app/build/test-results/testDebugUnitTest/*.xml')) From d29241459c280e0fd5eda800c5842459ccfd80e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 14:21:07 +0000 Subject: [PATCH 5/5] Give the build a timeout so a hung step cannot sit for six hours 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 Claude-Session: https://claude.ai/code/session_01JfCFkZp4NiGPGjTKwchmxj --- .github/workflows/build-apk.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 3b2a01a..2b022ed 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -17,6 +17,12 @@ jobs: name: Build APK runs-on: ubuntu-latest + # A green run takes about three minutes. Without a timeout a hung Gradle step sits on the + # runner for GitHub's six-hour default before anyone finds out, which is exactly what + # happened once: testDebugUnitTest wedged for quarter of an hour having taken 68 seconds on + # the commit before it. Thirty minutes is well clear of a cold cache and still fails fast. + timeout-minutes: 30 + # Secrets cannot be read from a step-level 'if', so the one the steps branch on is mapped # to an env var here and tested as env.KEYSTORE_BASE64 below. env: @@ -65,7 +71,10 @@ jobs: # The drift-tolerance rules in XReflect decide how far a hook may bend when Telegram moves # something, so a regression there is worth failing the build before an APK is produced. + # Tighter than the job budget so a wedged test run is named as such rather than showing up + # as the whole job timing out somewhere. - name: Run the unit tests + timeout-minutes: 12 run: ./gradlew :app:testDebugUnitTest --stacktrace # Gradle says nothing on success, so a run that executed no tests at all looks exactly like