The fastest way to understand an app you did not write is to start where it talks to itself. Not the UI, not the manifest — the boundary where Java calls into native code. That boundary is named, exported, and impossible to obfuscate away, and it hands you a map into everything else.
I ran the whole thing against Telegram 13.0.2, pulled from a Samsung Galaxy SM-M366B on Android 16. Telegram is the right teaching target precisely because it is open source: every finding can be checked against DrKLO/Telegram, so you learn whether your technique is sound rather than guessing. The tools are all boring and mostly preinstalled: adb, jadx, and the binutils trio nm / readelf / strings. gh for the cross-checking.
#Getting the APK off the device
A modern Android app is not one APK. It is an App Bundle, split at install time into the pieces a given device actually needs:
adb shell pm list packages -3 # third-party packages only
adb shell pm path org.telegram.messengerpm path returns several lines, not one:
package:/data/app/~~.../base.apk
package:/data/app/~~.../split_config.arm64_v8a.apk
package:/data/app/~~.../split_config.xxhdpi.apkbase.apk is the Java and the manifest. split_config.arm64_v8a.apk carries the native libraries for this phone's CPU ABI. split_config.xxhdpi.apk is density-specific resources — irrelevant here. You want base plus the arm64 split; that pair contains everything that runs.
adb pull /data/app/~~.../base.apk .
adb pull /data/app/~~.../split_config.arm64_v8a.apk .One real-world wrinkle: on this device pm list packages threw a SecurityException complaining about a secondary user profile. If you hit that, it is not a dead end — pm path <pkg> with the package name typed out still works, because you are no longer asking the system to enumerate across profiles. Knowing the package name in advance routes around the thing that fails.
#Reading the native library with nothing but binutils
Unzip the arm64 split and the payload falls out:
unzip -o split_config.arm64_v8a.apk -d arm64
ls -la arm64/lib/arm64-v8a/
# libtmessages.49.so ~22 MBYou do not need a disassembler to learn an enormous amount from this file. Start by confirming what it even is:
readelf -h arm64/lib/arm64-v8a/libtmessages.49.so
# Class: ELF64
# Machine: AArch64ELF64, AArch64 — a 64-bit ARM shared object, exactly what the arm64 split should contain. Now the part that does the real work. List the exported dynamic symbols:
nm -D --defined-only arm64/lib/arm64-v8a/libtmessages.49.so | grep ' Java_'Every JNI entry point is exported under a name the JVM mangles from the fully-qualified Java method: Java_org_telegram_messenger_Utilities_.... Telegram exports 383 of them. Grouped by their class prefix, the native half of the application draws its own architecture diagram:
nm -D --defined-only arm64/lib/arm64-v8a/libtmessages.49.so \
| grep ' Java_' | sed 's/.* Java_//; s/_[^_]*$//' | sort | uniq -c | sort -rn- WebRTC /
voip_NativeInstance— voice and video calls run natively. Utilities— the crypto helpers.SQLite_*— the database layer is native, not the AndroidSQLiteDatabaseyou would expect.RLottieNative/AnimatedFileNative— sticker and media rendering.WalletEngine2— a TON crypto wallet, which I did not expect to find sitting in a messenger's.so.
strings fills in the constants the symbols only hint at:
strings arm64/lib/arm64-v8a/libtmessages.49.so | grep -i 'sqlite' # bundled version
strings arm64/lib/arm64-v8a/libtmessages.49.so | grep 'org/telegram' # RegisterNatives targets
strings arm64/lib/arm64-v8a/libtmessages.49.so | grep -i 'wamr\|wasm' # a WASM runtime pathThat last one is the surprise: embedded in the binary is WAMR, a WebAssembly runtime, apparently used for call logic. And grep 'org/telegram' surfaces the class names the library registers with RegisterNatives at load time — org/telegram/tgnet/ConnectionsManager among them. Those are not exported as Java_ symbols because they are wired up dynamically, but the string constants give them away. The binary is telling you which Java classes it reaches into before you have opened a single .java file.
#Decompiling the Java
sudo pacman -S jadx
JAVA_OPTS=-Xmx6g jadx -j 6 --show-bad-code -d out base.apk-Xmx6g because the default heap will not survive the size of this app, -j 6 to use six threads, --show-bad-code so jadx emits its best-effort output instead of silently dropping methods it cannot fully resolve. Telegram decompiles to roughly 11,885 classes across about 16,000 Java files, and it reports 235 decompile errors along the way. That is not a failure. On a codebase this size a few hundred methods that jadx cannot perfectly reconstruct is expected, and --show-bad-code means you still get something to read for each of them.
Then you open the tree and immediately hit the wall every real app puts up: the class names are obfuscated. Not Telegram's own org.telegram.* packages, which ship with real names, but the dependencies and a great deal else — single-letter and short mangled names like oh0.java. There is no navigating this by class name. The name tells you nothing.
#The JNI boundary is the way in
This is where the native work pays off. Those 383 Java_... exports name the exact Java classes and methods that cross into native code — in plain, un-obfuscated form, because the JVM has to find them by their real mangled signature at runtime. They cannot be renamed without breaking the link. So the native library is a directory of true names pointing straight into the Java you are trying to read.
From there the technique is always the same: never search for a class, search for a string that a class must contain. A distinctive SQL fragment, a log message, a getString(R.string...) key, a pragma — something stable that obfuscation cannot touch because it is data, not an identifier.
grep -rl 'PRAGMA secure_delete' out/sources/
grep -rl 'ConnectionsManager' out/sources/
grep -rln 'CREATE TABLE messages' out/sources/Want the class behind a native call? Take the method name out of the Java_ symbol, grep for it. Want the code that builds the message database? Grep for the CREATE TABLE statement. The obfuscated filename it lands in does not matter — you arrived by content, and the content is what you were after.
#Cross-verifying against the source
Decompilers lie in small ways. A reconstructed loop, a synthesized local, an inlined constant — none of it is guaranteed to match what the author wrote. With a closed app you simply live with that uncertainty. With Telegram you can erase it, and in doing so turn the exercise into a miniature reproducible-builds check: does the "it's open source, here's the code" claim actually hold for the binary on my phone?
gh api -X GET search/code -f q='WalletEngine2 repo:DrKLO/Telegram'
gh api repos/DrKLO/Telegram/contents/TMessagesProj/jni/tgnet/ConnectionsManager.cpp \
-H 'Accept: application/vnd.github.raw'The search finds the file that defines a symbol; the raw fetch pulls it down so you can read it beside your decompiled output. Checked against the 13.0.2 tag, the shipped library matched the public source — same version, same code paths. That is the whole payoff: not "I found something hidden," but "I confirmed the thing they publish is the thing they ship." For an app that stakes its reputation on that claim, being able to verify it from your own device is the point.
#Ethics and legality, briefly
Analyse your own device and your own copy of the app, and nothing else. Open-source apps are the ideal subject exactly because you can check your findings — you are learning a method, not prying into a secret. Do not redistribute decompiled code. Do not point this at authentication, payment, or crypto-wallet apps. Do not try to defeat anti-tamper or integrity checks; that is where legitimate study ends and attack begins.
The security posture is itself interesting to observe, as an observation rather than a recipe. Telegram computes a SHA-256 of its own signing certificate at startup and sends it to the server, so tamper detection is handled server-side rather than by a client check you could patch. It uses Google Play Integrity during phone-number login. And notably, the binary contains no client-side root, Frida, or emulator detection at all. Seeing what an app does and does not defend tells you how it thinks about trust — and you can read all of it with tools that came with your machine.