Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions .github/workflows/build-apk.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
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

# 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:
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

# 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
# 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: |
# 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'))
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
# 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)
# 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"

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" <<EOF
## TeleVip ${{ steps.collect.outputs.version }} built

Commit \`${{ steps.collect.outputs.short_sha }}\`.

### 1. Get the APK

1. Scroll to the **Artifacts** section at the bottom of this page.
2. Download **$artifact**.
3. Unzip it. GitHub always wraps artifacts in a \`.zip\`, so the \`.apk\` is inside.

| File | Can you install it? |
|---|---|
| \`...-debug.apk\` | **Yes** - signed with the debug key |
| \`$release_apk\` | $release_note |

### 2. Install it

1. Copy the APK to your phone and open it. Allow installing from unknown sources if asked.
2. Open your **LSPosed** or **Vector** manager app.
3. Go to **Modules**, enable **TeleVip**, and tick the Telegram clients you want it in.
4. **Force stop** Telegram and reopen it. The module only attaches on a fresh start.

Needs LSPosed 1.10+ or Vector 2.2+. TeleVip is a libxposed API 102 module, so it will
not load on LSPosed 1.9.x, EdXposed or LSPatch.

### Why is the release APK unsigned?

The release variant is only signed when the repository has signing secrets set:
\`KEYSTORE_BASE64\` (the keystore, base64 encoded), \`KEYSTORE_PASSWORD\`,
\`KEY_ALIAS\` and \`KEY_PASSWORD\`. Without them it is built unsigned, and Android
refuses to install an unsigned APK with "App not installed" - so use the debug APK,
or add the secrets under **Settings -> Secrets and variables -> Actions**.
EOF
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
68 changes: 65 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,77 @@ 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.

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
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/com/my/televip/Class/ClassLoad.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/com/my/televip/TeleVip.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down
Loading
Loading