I had an Android APK and an objective: I wanted to understand what the application downloaded and save that data in a usable offline format.
My work so far has mostly been in web development, so I approached the problem with a web developer’s instincts. When I want to understand how to scrape a new website, I open DevTools, switch to the Network tab, click around, and watch the requests appear. Somewhere in there is usually an API endpoint returning JSON, along with the headers, parameters, and authentication needed to reproduce it.
I expected inspecting an Android application to be a more inconvenient version of the same process.
As I found out, it was not.
An APK is compiled software, not a conveniently packaged source repository. There is no built-in Network tab exposing every meaningful request. Even when traffic can be observed, the interesting data might be encrypted before it reaches the network and decrypted only after it returns. Downloaded files may be split into pieces, assembled through application-specific logic, stored in databases that do not open cleanly outside the app, and then encrypted again at the field level.
What began as “find the request” became my first mobile app reverse-engineering project.
Every time I opened one layer, there was another smaller, stranger box inside it.
The easy method lasted about five minutes
Like a good engineer, I tried the simplest solution before decompiling anything or learning Frida.
My first attempt used Objection’s convenient APK-patching workflow. Instead of manually setting up runtime instrumentation, Objection could inject Frida Gadget directly into the app and rebuild it for inspection.
The resulting APK would not start. It got stuck on a blank grey screen.
At first, I assumed I had broken something during the patching process. It soon became clear that the failure was deliberate. Modifying and re-signing the APK had changed its signature, and the application was checking for exactly that kind of tampering.
That was the first indication that the people who built this app had put real thought into preventing inspection and repackaging. This was not going to be a matter of pointing a proxy at it and reading some JSON.
JADX and the guesstimated Java
To get some more visibility into the app, the first tool I reached for was JADX.
JADX takes the compiled bytecode inside an Android application and attempts to reconstruct Java-like source code from it. The word “reconstruct” matters. It does not restore the original project as the developer wrote it. It produces an approximation based on the compiled program.
I started calling it guesstimated Java.
The result technically resembled source code, but it carried all the fingerprints of decompilation: synthetic variables, awkward control flow, anonymous-looking methods, and classes were named a, b, and c. Somewhere there was probably a d, but I never found the courage to look for it
The app had also been obfuscated, which meant much of the semantic information had been deliberately removed. Even when the decompiled code was structurally correct, it rarely explained why anything existed.
Still, JADX made the application more searchable than before, so it was a step in the right direction.
I could look for references to networking libraries such as OkHttp, inspect uses of Android’s cryptography APIs, search for SQLite access, follow file-writing code, and find strings that looked like paths or server addresses. Now, instead of having no visibility into the app’s code, I had too much sludge to wade through.
Searching through it felt like nothing I had ever done before, because I had only ever read source code meant to be read.
A method calling Cipher.getInstance("AES/GCM/NoPadding") was probably cryptographically interesting, but that did not tell me whether it was used for requests, responses, database fields, or media files. A function writing bytes to disk might have handled an important package or an irrelevant cache file. Static code could suggest possibilities, but it could not tell me which paths were actually being executed.
That was where language models became useful.
LLM-assisted code archaeology
I began feeding sections of the decompiled code to LLMs and asking questions about what they might be doing.
Was this class responsible for preparing an encrypted command? Was that value a salt, or just an identifier? Which method appeared to run before a request was encrypted? Where did a downloaded file go after the response body was read? Which functions looked important enough to observe at runtime?
The models were really quite useful for navigating unfamiliar patterns and long chains of obfuscated calls. As you would expect, their pattern recognition meant they could recognise common Android and Java structures even when the variable names were meaningless. They helped narrow thousands of lines of decompiled output into a smaller set of classes and methods worth investigating.
An interesting finding was that, for much of this work, I used Chinese language models.
They were not necessarily more accurate. If anything, they're a hair behind the frontier models, but they tended to be more willing to discuss reverse engineering, runtime instrumentation, anti-tamper code, and cryptographic routines. Several mainstream assistants would refuse fairly ordinary questions as soon as the surrounding code involved integrity checks or application instrumentation. The Chinese models generally had fewer blanket restrictions around that kind of dual-use material.
So now I had a model that would talk to me about this, but a model I couldn't trust. The willingness was useful, but it was not the same thing as correctness.
I therefore treated the models as hypothesis generators.
JADX showed me what the code might be. The LLMs helped explain what it might mean. Neither could prove what the application actually did.
For that, I needed to observe it while it was running.
Frida: watching the app work
Frida allows instrumentation code to be injected into a running process. On Android, it can hook Java methods as they execute, inspect their arguments and return values, log when they are called, and sometimes replace their behaviour.
This turned out to be very useful.
JADX gave me a frozen, reconstructed picture of the program. Frida let me stand inside the running application and watch real values move through it.
Instead of patching the APK again, I installed the original application on a rooted emulator and ran frida-server separately. This meant I could attach to the running process without rebuilding and re-signing the package.
Before I could do much useful observation, I still had to keep the app alive under instrumentation.
The application contained runtime protections related to signature checking, tamper detection, and process termination. Some of my earliest Frida hooks existed simply to prevent the app from ending the experiment whenever it noticed something unusual.
Once it remained running, I began placing hooks around methods that looked useful.
I was not trying to understand every obfuscated class. That would have taken far longer than the project justified. Instead, I looked for boundaries where the application briefly converted difficult data into useful data:
Before plaintext commands were encrypted
After server responses had been decrypted
When the app selected a server or mirror
When URLs became HTTP requests
When SQLite queries were executed
When downloaded bytes became files
When encrypted database values became visible content
My first scripts hooked broadly. Some of those hooks interfered with obfuscated bridge methods in the app’s HTTP stack and caused errors that the application itself could see. Logging too much or intercepting the wrong overload could alter timing, disrupt control flow, or make a previously working network call fail.
The observer was changing the thing being observed.
I replaced the broad hooks with a more focused download tracer around OkHttp request construction, actual network calls, responses, redirects, and file output. That exposed the exact URLs being requested, User-Agent values, Range headers, redirect behaviour, local filenames, and the naming scheme used for split packages.
The process became iterative: form a theory from JADX and the LLMs, hook the suspected method, observe what happened, narrow the hook if it disturbed the application, and trust only behaviour that could be repeated.
I found more than an endpoint
I had gone looking for a clean API request I could replicate for multiple files. What I found was a delivery system that was significantly more difficult to scrape than a clean JSON endpoint.
The application used a catalogue database describing the content available for download. It contained package names, paths, types, part information, and expected sizes.
The packages themselves were not always delivered as one ordinary ZIP file. They could arrive as numbered parts such as:
package.1
package.2
package.3
The app wrote temporary .download files, renamed completed parts, concatenated them into a larger temporary archive, validated the result, and then extracted it.
I pieced this together from the JADX output and my runtime observations, then built tooling to reproduce the process locally.
def reconstruct(parts: dict[int, Path], ordered_nums: list[int], output: Path, overwrite: bool) -> int:
if output.exists() and not overwrite:
raise SystemExit(f"Output already exists: {output} (use --overwrite)")
output.parent.mkdir(parents=True, exist_ok=True)
total = 0
with output.open("wb") as out_f:
for num in ordered_nums:
part_path = parts[num]
size = part_path.stat().st_size
print(f"[+] append part {num}: {part_path.name} ({size} bytes)")
with part_path.open("rb") as in_f:
shutil.copyfileobj(in_f, out_f, length=1024 * 1024)
total += size
return total
The network behaviour around the download mattered almost as much as the URL itself. Range requests were used for partial content, and redirects had to preserve headers such as User-Agent and Range. A request could point to the correct file and still fail because it did not behave enough like the original application.
Eventually, the downloader and reconstruction code produced extracted packages.
This looked like victory. I had a folder of media and a SQLite database.
Then I opened the contents.
The downloader downloaded encrypted nonsense
Most of the downloaded content was still encrypted. Media files had the right file extensions, but images would not open and audio or video would not play.
The databases presented a separate problem. Some referenced SQLite virtual-table extensions that existed inside the app but not in the standard SQLite installation on my Mac. I worked around this by creating temporary copies and removing the incompatible schema definitions, which made the ordinary tables accessible.
Encrypted database fields were marked with a prefix such as v2:. Reproducing the app’s AES-GCM decryption required recovering the full data format, including the nonce, authentication tag, associated data, and an optional compression step.
Once that worked, the scripts could decrypt questions and answers, join them together, and export normalised JSON. Later, I added support for library databases that contained a Docs table instead of question-bank tables.
Media used different encryption logic. Newer files followed an authenticated-encryption path, while older files used a legacy cipher and key-derivation process.
I also learned that a decryption function returning bytes does not prove those bytes are correct. The scripts validated the results using file signatures and expected structure: images had to resemble real image files, audio and video needed recognised headers, and HTML had to look like an actual document.
At this point, I had most of the important pieces:
APK
↓
JADX and LLM-assisted analysis
↓
Frida runtime observation
↓
Catalogue and package discovery
↓
Split archive reconstruction
↓
SQLite repair
↓
Database-field decryption
↓
Media decryption
↓
Normalised output
Naturally, I then tried to combine these individual pieces into one big flow, with help from an AI agent.
That turned out to be a bad idea.
The 1,200-line detour
Once the individual components worked, I built an interactive terminal application around them.
It could search the catalogue, select content, choose mirrors, download package parts, reconstruct archives, decrypt databases, decrypt media, generate question-and-answer exports, and write manifests.
The main script grew to roughly 1,200 lines.
This was not entirely pointless. Connecting the pieces proved that the recovered behaviour could form something resembling an end-to-end pipeline. It also exposed all the unpleasant reliability problems hidden beneath “the downloader works.”
Downloads timed out. Some failures needed retries. Existing parts needed validating against catalogue metadata. Redirects quietly dropped important headers. Mirrors did not always behave identically. Different catalogue versions used slightly different schemas and paths.
Eventually, I realised I was spending increasing amounts of time rebuilding a part of the system that the original application already handled reliably, and so the TUI was scrapped.
More importantly, I did not need a general-purpose package browser. I already knew which databases I wanted, so I could do some manual work downloading them in the emulator myself.
The downloader I did not need
The final workflow was less elegant than the one-command terminal application I had imagined, but it solved the actual problem.
I ran the official application inside an Android emulator and used it to download the specific databases I needed. The app handled authentication, server selection, redirects, retries, and all the other networking behaviour it had already been designed to handle.
Once the downloads completed, I located the resulting files in the emulator’s Android filesystem and copied them onto my Mac. Which feels quite anticlimactic, to have all the tooling depend on what is functionally a drag and drop, but that's how it goes.
Known list of databases
↓
Official app downloads them in the emulator
↓
Files are copied manually onto the Mac
↓
SQLite schemas are repaired where necessary
↓
Database fields are decrypted
↓
Questions, answers, or documents are normalised
↓
Associated media is decrypted and validated
The final script was therefore not really a downloader and decrypter. It was a batch decoder.
It discovered local database files, created temporary working copies, repaired incompatible schemas, decrypted records, joined questions with answers, handled library documents, located related media directories, processed modern and legacy media, validated the results, wrote manifests, and exported normalised JSON.
It could process multiple databases concurrently, and a failure in one job did not have to stop the entire batch.
The responsibility split became clear:
Official Android app:
- Authentication
- Server interaction
- Downloads
Custom local tooling:
- SQLite repair
- Database decryption
- Media decryption
- Validation
- Normalisation
- Batch processing
This was not the pristine end-to-end pipeline I had originally pictured, and one stage remained completely manual, the moving files from the Android emulator onto the Mac.
The project spanned 17 commits between July 2 and July 11, 2026. I started this project looking for the Android equivalent of a Network tab. I expected to find a request, reproduce it, and move on.
I ended up learning how to read decompiled code, use LLMs to navigate obfuscation, instrument a running app with Frida, reconstruct its package format, and reproduce the decryption paths for its databases and media.
For a while, I thought finishing the project meant rebuilding the entire flow myself. It did not. The official app was already perfectly capable of downloading the files I needed. My tooling only had to take over where the app stopped being useful to me: turning those files into readable, structured data.
The final workflow was not fully automatic, but it was repeatable, and it solved the problem at hand.

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.