Environment setup
Same setup as lesson 01: Xcode 27 with an iOS 27 device, Android Studio with minSdk 30 or higher on a real phone. Python tools run with uv run inside the llm-survival project (Python 3.11+, uv add torch mlx mlx-lm).
Generate a checksum for your model file on the Mac before you upload it:
shasum -a 256 model.bin | cut -d ' ' -f 1 > model.bin.sha256
What goes wrong
- The user opens the app on a train, the download is at 60%, and the network drops.
- The system kills the app in the background to get memory back. The download task in your process is gone.
- The file finished, but it is short or corrupt, and the runtime crashes when it maps it.
Rules: download in a way the system can keep going, resume instead of restart, and never load a file you did not check.
iOS: background URLSession and resume data
A URLSession with a background configuration keeps running after your app leaves memory. If a download fails or you cancel it, you can get resume data and continue with downloadTask(withResumeData:).
import Foundation
import CryptoKit
final class ModelDownloader: NSObject, URLSessionDownloadDelegate {
static let shared = ModelDownloader()
private let resumeKey = "model.resumeData"
private lazy var session: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: "dev.edgefde.model-download")
config.isDiscretionary = false
config.sessionSendsLaunchEvents = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
var finalURL: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("model.bin")
}
var expectedSHA256 = "" // from your manifest, next to the model URL
func start(from remote: URL) {
if let data = UserDefaults.standard.data(forKey: resumeKey) {
UserDefaults.standard.removeObject(forKey: resumeKey)
session.downloadTask(withResumeData: data).resume()
} else {
session.downloadTask(with: remote).resume()
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let error = error as NSError? else { return }
if let data = error.userInfo[NSURLSessionDownloadTaskResumeData] as? Data {
UserDefaults.standard.set(data, forKey: resumeKey) // resume next time
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
// Check the file before it gets near the runtime.
guard let digest = try? sha256(of: location), digest == expectedSHA256 else {
try? FileManager.default.removeItem(at: location)
print("download: checksum mismatch, file removed")
return
}
let parent = finalURL.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true)
_ = try? FileManager.default.replaceItemAt(finalURL, withItemAt: location)
}
}
func sha256(of url: URL) throws -> String {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var hasher = SHA256()
while let chunk = try handle.read(upToCount: 8 * 1024 * 1024), !chunk.isEmpty {
hasher.update(data: chunk)
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
Two details matter. First, the file at location is temporary, so move it inside the delegate method. Second, hash in chunks. Reading a 2 GB file into one Data can cause the jetsam kill you are trying to avoid. Also handle application(_:handleEventsForBackgroundURLSession:completionHandler:) in your app delegate so the system can wake your app when the background download ends.
If your model is a Core AI .aimodelc from ahead-of-time compilation, Apple suggests the Background Assets framework for hosted model files. The Core AI first token course covers that path.
Android: WorkManager and HTTP Range
On Android, run the download in a CoroutineWorker. WorkManager keeps the job across process death and reboots. Write to a .part file and ask the server for the rest with an HTTP Range header.
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
class ModelDownloadWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val url = inputData.getString("url") ?: return Result.failure()
val expected = inputData.getString("sha256") ?: return Result.failure()
val part = File(applicationContext.filesDir, "model.bin.part")
val done = File(applicationContext.filesDir, "model.bin")
val conn = URL(url).openConnection() as HttpURLConnection
if (part.exists()) conn.setRequestProperty("Range", "bytes=${part.length()}-")
val append = conn.responseCode == HttpURLConnection.HTTP_PARTIAL
if (!append && conn.responseCode != HttpURLConnection.HTTP_OK) return Result.retry()
conn.inputStream.use { input ->
FileOutputStream(part, append).use { out -> input.copyTo(out, 1 shl 20) }
}
if (sha256(part) != expected) {
part.delete()
return Result.retry()
}
return if (part.renameTo(done)) Result.success() else Result.retry()
}
private fun sha256(file: File): String {
val md = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buf = ByteArray(1 shl 20)
while (true) {
val n = input.read(buf)
if (n <= 0) break
md.update(buf, 0, n)
}
}
return md.digest().joinToString("") { "%02x".format(it) }
}
}
If the server answers 200 instead of 206, it ignored the range, so start again from zero. The code handles that: append is false and the stream overwrites the part file.
Process death in the middle of a chat
The system can kill your app while it is in the background, even in the middle of a long answer. When the user comes back, they expect the chat to still be there.
- Save each finished message to disk as soon as it is done. Do not keep the only copy in memory.
- Save a small "generation in progress" record: the prompt id and the time it started.
- On launch, if that record exists, show the last answer as "stopped" with a Retry button. Do not try to restore the model's internal state.
- Load the model lazily after launch, through the owner from lesson 02. Do not load it in
application(_:didFinishLaunchingWithOptions:)orApplication.onCreate.
For long jobs that must finish, see Durable On-Device AI Workflows.
Sources
Key concepts
- Use a background
URLSessionon iOS and WorkManager on Android so downloads survive process death. - Resume with resume data on iOS and an HTTP
Rangeheader on Android. - Check a SHA-256 checksum in chunks before you move the file into place.
- After process death, restore saved messages and mark the cut answer as stopped.
Takeaways
- Never hand a runtime a file you have not checked.
- Never hash a big model by reading it into memory at once.
- Save chat messages as they finish, and load the model lazily after launch.