Your first ticket on a mobile AI repo is almost never the model itself. It is usually a small line in the sprint board: "make the offline path work on a real phone." So you mirror a model artifact to some dev endpoint, tap download on a test device, background the app for thirty seconds to answer a Slack message, and come back to a progress bar that either restarted at zero or sits frozen at 34%. Nobody in the repo can tell you which of those two is expected, and that is the actual bug you were hired to find.
This post is a first-hour script for that exact moment. It gives you a preflight check for the endpoint, a byte ledger you can attach to your first PR, and a four-run matrix that turns "it seemed to work" into a table a reviewer can re-run. I am describing the procedure and the code, not reporting finished numbers: every measurement slot below is yours to fill on your own device, and I say so explicitly wherever a value appears.
What This Test Is Not
It is not a battery benchmark, an inference latency study, or a claim about any model's quality. It answers one narrower question: when the OS interrupts a long file transfer, does your client resume, restart, or quietly hand the app a corrupt artifact? Those three outcomes look identical in a demo and completely different in a crash report.
Step 0: Assemble the Environment Record First
Before you write a line of code, open a scratch file and fill in the boring facts. You will need them in the PR description anyway, and reviewers trust a table far more than an adjective.
- Device model and RAM class, plus whether it is a physical unit or an emulator.
- OS build string, not just the marketing version number.
- Framework version and the exact HTTP client version doing the transfer.
- Starting battery percentage, charging state, and whether low-power mode was on.
- Network pair used for the flip test (for example, one Wi-Fi AP and one cellular radio), with signal quality noted.
- Artifact size and its SHA-256 hash, pinned somewhere outside the download code.
A junior-friendly shortcut: keep this file as docs/download-lifecycle.md in the repo. Your first PR adds evidence, not just code, and the next person inherits the template.
Step 1: Preflight the Endpoint With curl
Most "resume is broken" bugs are actually "the server never supported ranges." Check before you debug your own client, because the failure mode is deceptive: your resume code runs, gets a full body back, and happily appends it to a partial file.
# 1. Does the endpoint advertise range support at all?
curl -sI https://YOUR_DEV_ENDPOINT/model.bin | grep -i -E 'accept-ranges|content-length'
# 2. Ask for a 1 KiB slice and read the status line.
curl -sI -H 'Range: bytes=0-1023' https://YOUR_DEV_ENDPOINT/model.bin | head -n 5
# 3. Confirm the body length and hash of what you actually get.
curl -s https://YOUR_DEV_ENDPOINT/model.bin -o model.bin
shasum -a 256 model.bin
You want 206 Partial Content with a Content-Range: bytes 0-1023/… header from the second command. A 200 OK means the endpoint ignored your Range header, and your resume logic is fiction on this host. Record both raw outputs in the scratch file; they are the evidence that lets a reviewer reproduce your conclusion without re-running anything.
If you are pointing the client at a hosted dev endpoint during onboarding instead of standing up your own VM, this preflight is exactly where a free server option earns its keep. In this account's workflow I used MonkeyCode's free model access and free server option as the throwaway endpoint the client talks to during the first week, so a junior never needs production credentials to test a download path. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Treat it as one convenient endpoint among many, and run the three curl commands above against whatever you actually use, since range behavior is a property of the host, not of the tool you picked.
Decision table for what the preflight tells you
| curl result | What it means | What to do in the client |
|---|---|---|
206 + Content-Range
|
Ranges work | Keep resume logic, test the flip matrix below |
200 only |
Range ignored | Disable resume for this host, always download full |
206 but wrong total in Content-Range
|
Server-side size mismatch | Block the download, alert, pin the hash |
Timeout or 5xx
|
Host unreliable | Retry with backoff, never mark the artifact usable |
Step 2: Emit a Byte Ledger, Not a Progress Bar
A progress bar tells the user a story. A ledger tells the reviewer what happened. The artifact below is runnable Kotlin against OkHttp, and it is small on purpose: one JSON line per event, flushed immediately so a process kill cannot erase the last record.
class ResumeLedger(private val sink: (String) -> Unit) {
fun event(kind: String, bytes: Long, net: String, appState: String, note: String = "") {
sink("""{"t":${'$'}{System.currentTimeMillis()},"kind":"$kind","bytes":$bytes,"net":"$net","appState":"$appState","note":"$note"}""")
}
}
The transfer itself records the offset it resumed from, and refuses to trust a 200 response after asking for a range:
fun download(url: String, dest: File, ledger: ResumeLedger) {
val have = if (dest.exists()) dest.length() else 0L
val req = Request.Builder().url(url).apply {
if (have > 0) header("Range", "bytes=$have-")
}.build()
client.newCall(req).execute().use { resp ->
if (have > 0 && resp.code != 206) {
ledger.event("ignored_range", have, net(), state())
dest.delete()
return download(url, dest, ledger) // full restart, recorded honestly
}
ledger.event("open", have, net(), state(), "code=${resp.code}")
resp.body!!.byteStream().use { input ->
FileOutputStream(dest, have > 0).use { out ->
val buf = ByteArray(64 * 1024)
var total = have
while (true) {
val read = input.read(buf); if (read <= 0) break
out.write(buf, 0, read); total += read
ledger.event("progress", total, net(), state())
}
}
}
ledger.event("done", dest.length(), net(), state())
}
}
Two honesty notes before you copy this. First, if your app layer is React Native or Flutter, the streaming body may not be exposed at the framework level, so the ledger belongs in a small native module rather than a JavaScript fetch wrapper; that is a real constraint, not a workaround to hide. Second, net() and state() are placeholders for your own connectivity and lifecycle readers, and you should log the raw values they return rather than a friendly string like "wifi".
Step 3: Run the Four-Condition Matrix
Now the part that makes the PR worth reviewing. Run the same artifact four times, and after each run verify the hash against the pinned value before the app is allowed to use the model.
- Steady network, foreground. Baseline. Record total wall time and bytes written.
- Background at roughly a third of the file. Home button, wait 30 s, return. Record whether bytes continued, paused, or reset.
- Network flip mid-transfer. Start on Wi-Fi, disable Wi-Fi at the same byte offset, let the cellular radio take over. Record the resumed offset and any duplicate byte count.
- Hard kill. Force-stop the process mid-download, relaunch, and observe whether anything resumes at all.
For each run capture: offset at interruption, offset at resume, duplicate bytes, final size, hash match, user-visible message, and whether the app recovered, restarted, or silently lost the artifact. That last column is the one I care about most, because "silently disappeared" is the outcome that ships.
Reading the results without overclaiming
One device and one network pair cannot tell you how a whole device fleet behaves. What it can tell you is whether your resume code has a logic error, which is usually the same error on every phone. Report the run as a single-environment result and ask readers to contribute their own device, OS, transition steps, and outcome.
Step 4: First PR and First Rollback
Your first PR should contain the ledger, the matrix table, and one small production change: a fetch mode switch. Something like MODEL_FETCH_MODE = single_shot | resume, defaulting to resume in debug and single_shot in release until the matrix is green.
The rollback rehearsal takes five minutes and is the part juniors skip. Flip to single_shot, repeat run 4, and confirm the app still reaches a usable offline state by downloading from zero. If a full restart under a hard kill leaves the app permanently stuck, your rollback is not a rollback, it is a second bug, and you found it before a release engineer did.
Limitations and Who Should Skip This
The ledger is not energy instrumentation; if you need milliwatt-level claims, use platform power profilers and say so. It also assumes you can pin an artifact hash, which rules out teams pulling mutable model URLs. And it says nothing about inference correctness after download, only that the bytes on disk are the bytes you expected. If your product ships model updates without hashes or version tags, fix that first, because this matrix will just document chaos.
If you run the four conditions on your own hardware, I would like the boring details: device, OS build, transition steps, and whether the download recovered, restarted, or vanished.
Top comments (0)