
On-device model repository downloader with disk-space preflight, durable resumable staging across process death, per-file SHA‑256 verification, atomic commit, and selective file filtering.
Downloads AI model repositories to Android and iOS devices, and refuses to do it badly.
Fetches a HuggingFace, ModelScope or Ollama repo, refuses to start without the disk space to finish it, and verifies every published SHA-256 before committing anything. A failed or interrupted download resumes across process death.
:ferry is a Kotlin Multiplatform library — JVM and iOS today. A commonMain consumer (an iOS app,
a shared module) declares one coordinate and Gradle resolves the target-specific artifact for
whichever platform it's building:
implementation("dev.thuat:ferry:0.3.0")A JVM-only or Android-only consumer declares the exact same coordinate — Gradle resolves it to
ferry-jvm under the hood, nothing platform-specific to spell out:
implementation("dev.thuat:ferry:0.3.0")
// Optional and additive, JVM/Android only. Only if you want WorkManager backgrounding:
implementation("dev.thuat:ferry-work:0.2.0")import okio.Path.Companion.toOkioPath
val ferry = Ferry.huggingFace()
ferry.download("google/gemma-2-2b-it", context.filesDir.toOkioPath()) { progress ->
when (progress) {
is RepoProgress.CheckingSpace -> …
is RepoProgress.Downloading -> …
is RepoProgress.Skipped -> … // already staged and verified; no bytes moved
is RepoProgress.Verifying -> …
is RepoProgress.Complete -> …
}
}.onFailure { error ->
if (error is InsufficientSpaceException) {
// "needs 4.1 GB, 2.3 GB free", before a single byte was transferred
}
}Three hubs, same call. dir is an okio.Path — context.filesDir.toOkioPath() above is how a
java.io.File becomes one; on iOS, build the Path directly with okio.Path.Companion.toPath().
Ferry.huggingFace().download("google/gemma-2-2b-it", dir)
Ferry.modelScope().download("Qwen/Qwen2.5-0.5B-Instruct", dir)
Ferry.ollama().download("qwen2.5:0.5b", dir)A repo with many quantisation variants doesn't force downloading all of them — fileFilter
selects a subset by path, and every guarantee (space preflight, resume, verification, atomic
commit) applies to just that subset:
ferry.download("bartowski/Qwen2.5-1.5B-Instruct-GGUF", dir, fileFilter = Regex("Q4_K_M"))Each takes a Ktor HttpClient if you have one. Pass it if you do: every request Ferry makes then
travels through your interceptors, your timeouts and your proxy config. Already have an
OkHttpClient? Wrap it rather than build a fresh one:
Ferry.huggingFace(client = HttpClient(OkHttp) { engine { preconfigured = yourOkHttpClient } }):ferry has no android.* reference in it, so a consumer on Android declares its own
<uses-permission android:name="android.permission.INTERNET" />.
Not a feature list. Promises the implementation holds and the tests enforce.
| # | guarantee | the failure it prevents |
|---|---|---|
| 1 | Never a partial model | files land one by one and a loader picks up a half-written repo |
| 2 | Never a corrupt model | trusting a 200, or verifying against the wrong hash |
| 3 | Never starts what can't finish | 4 GB model onto 3 GB free, failing at 91% |
| 4 | Resumable across a dropped connection, a failed attempt, or the process dying | a multi-gigabyte download restarting from byte zero after a kill or a crash |
Guarantee 3 is the one neither reference implementation has (see Why this exists).
Guarantee 4 covers more than "resumable" usually means. Staging is durable, so a failed attempt, a
cancellation, or the process dying all leave it exactly as far as it got, and a later download call
for the same repo id resumes from there rather than from zero. Nothing deletes staged bytes until a
download commits the repo or the caller calls abandonStaging. stagedBytes(repoId, into) reports
how much is already there, the number behind a "Resume, N already downloaded" row.
Two things stay out of scope. Deliberate pause, meaning recording that a stop was intentional
rather than a failure, needs cooperative cancellation threaded through the transfer loop; coroutine
cancellation already stops a transfer, but nothing records why. And resume is only as good as the
hub's validator: a .part with no ETag or Last-Modified restarts that file from byte zero
rather than risk resuming onto content that changed underneath it.
Two of the most prominent on-device-LLM Android apps wrote the same downloader independently:
Alibaba MNN (MnnLlmChat) |
Google AI Edge Gallery | |
|---|---|---|
| Transport | OkHttp + Range
|
HttpURLConnection + Range
|
| Backgrounding | foreground Service
|
CoroutineWorker + setForeground
|
| Repo of many files | yes | single file only |
| Verification | SHA-256 from ETag | not visible |
| Free-space check | no | no |
Nothing on Maven covers it. firebase-ml-modeldownloader handles Firebase-hosted models only;
tasks-genai and litert run models but never fetch them; Play for On-device AI delivers models
you own and bundle, not ones fetched from a hub at runtime.
:ferry-work is an optional module wrapping the download in a CoroutineWorker, with a
host-controlled foreground notification, WorkManager's retry and backoff, and a uniqueness guarantee
:ferry cannot provide. Nothing in :ferry depends on it.
implementation("dev.thuat:ferry-work:0.2.0")
WorkManager.getInstance(context)
.enqueueRepoDownload(repoId = "google/gemma-2-2b-it", into = filesDir, notificationId = 42)Setup, retry policy and design reasoning: docs/ferry-work.md.
:sample is a small Compose app demonstrating what Ferry refuses to do, not its downloads. A
progress bar cannot show a guarantee; only the absence of one is visible. It lists three real
HuggingFace repos plus a sabotage panel that fakes a nearly-full disk and corrupts a downloaded file
on demand, both built on seams the library already exposes.
./gradlew :sample:assembleDebug./gradlew :ferry:jvmTest:ferry's JVM target needs only a JDK; ./gradlew :ferry:iosSimulatorArm64Test additionally needs
Xcode and an iOS simulator. The rest of the repo (:ferry-work and :sample) also needs an Android
SDK with API 35, and a JDK between 17 and 21. The upper bound is Gradle 8.9's: a newer JDK fails
during script compilation with a bare IllegalArgumentException naming the JDK's own version and
nothing else.
JAVA_HOME=/path/to/jdk-21 ./gradlew :ferry:checkModelHub interface, and what
three hubs look like side by side. Read this before writing an adapter.:ferry-work setup and every design decision behind it.RepoProgress is a sealed interface and pause is unimplemented, so adding it later needs a new case,
which breaks any consumer's exhaustive when. ModelHub is likewise expected to grow a real error
taxonomy beyond a bare IOException. Both may break before 1.0.0. That is a deliberate use of what
0.x means in semver, not instability.
Apache 2.0
Downloads AI model repositories to Android and iOS devices, and refuses to do it badly.
Fetches a HuggingFace, ModelScope or Ollama repo, refuses to start without the disk space to finish it, and verifies every published SHA-256 before committing anything. A failed or interrupted download resumes across process death.
:ferry is a Kotlin Multiplatform library — JVM and iOS today. A commonMain consumer (an iOS app,
a shared module) declares one coordinate and Gradle resolves the target-specific artifact for
whichever platform it's building:
implementation("dev.thuat:ferry:0.3.0")A JVM-only or Android-only consumer declares the exact same coordinate — Gradle resolves it to
ferry-jvm under the hood, nothing platform-specific to spell out:
implementation("dev.thuat:ferry:0.3.0")
// Optional and additive, JVM/Android only. Only if you want WorkManager backgrounding:
implementation("dev.thuat:ferry-work:0.2.0")import okio.Path.Companion.toOkioPath
val ferry = Ferry.huggingFace()
ferry.download("google/gemma-2-2b-it", context.filesDir.toOkioPath()) { progress ->
when (progress) {
is RepoProgress.CheckingSpace -> …
is RepoProgress.Downloading -> …
is RepoProgress.Skipped -> … // already staged and verified; no bytes moved
is RepoProgress.Verifying -> …
is RepoProgress.Complete -> …
}
}.onFailure { error ->
if (error is InsufficientSpaceException) {
// "needs 4.1 GB, 2.3 GB free", before a single byte was transferred
}
}Three hubs, same call. dir is an okio.Path — context.filesDir.toOkioPath() above is how a
java.io.File becomes one; on iOS, build the Path directly with okio.Path.Companion.toPath().
Ferry.huggingFace().download("google/gemma-2-2b-it", dir)
Ferry.modelScope().download("Qwen/Qwen2.5-0.5B-Instruct", dir)
Ferry.ollama().download("qwen2.5:0.5b", dir)A repo with many quantisation variants doesn't force downloading all of them — fileFilter
selects a subset by path, and every guarantee (space preflight, resume, verification, atomic
commit) applies to just that subset:
ferry.download("bartowski/Qwen2.5-1.5B-Instruct-GGUF", dir, fileFilter = Regex("Q4_K_M"))Each takes a Ktor HttpClient if you have one. Pass it if you do: every request Ferry makes then
travels through your interceptors, your timeouts and your proxy config. Already have an
OkHttpClient? Wrap it rather than build a fresh one:
Ferry.huggingFace(client = HttpClient(OkHttp) { engine { preconfigured = yourOkHttpClient } }):ferry has no android.* reference in it, so a consumer on Android declares its own
<uses-permission android:name="android.permission.INTERNET" />.
Not a feature list. Promises the implementation holds and the tests enforce.
| # | guarantee | the failure it prevents |
|---|---|---|
| 1 | Never a partial model | files land one by one and a loader picks up a half-written repo |
| 2 | Never a corrupt model | trusting a 200, or verifying against the wrong hash |
| 3 | Never starts what can't finish | 4 GB model onto 3 GB free, failing at 91% |
| 4 | Resumable across a dropped connection, a failed attempt, or the process dying | a multi-gigabyte download restarting from byte zero after a kill or a crash |
Guarantee 3 is the one neither reference implementation has (see Why this exists).
Guarantee 4 covers more than "resumable" usually means. Staging is durable, so a failed attempt, a
cancellation, or the process dying all leave it exactly as far as it got, and a later download call
for the same repo id resumes from there rather than from zero. Nothing deletes staged bytes until a
download commits the repo or the caller calls abandonStaging. stagedBytes(repoId, into) reports
how much is already there, the number behind a "Resume, N already downloaded" row.
Two things stay out of scope. Deliberate pause, meaning recording that a stop was intentional
rather than a failure, needs cooperative cancellation threaded through the transfer loop; coroutine
cancellation already stops a transfer, but nothing records why. And resume is only as good as the
hub's validator: a .part with no ETag or Last-Modified restarts that file from byte zero
rather than risk resuming onto content that changed underneath it.
Two of the most prominent on-device-LLM Android apps wrote the same downloader independently:
Alibaba MNN (MnnLlmChat) |
Google AI Edge Gallery | |
|---|---|---|
| Transport | OkHttp + Range
|
HttpURLConnection + Range
|
| Backgrounding | foreground Service
|
CoroutineWorker + setForeground
|
| Repo of many files | yes | single file only |
| Verification | SHA-256 from ETag | not visible |
| Free-space check | no | no |
Nothing on Maven covers it. firebase-ml-modeldownloader handles Firebase-hosted models only;
tasks-genai and litert run models but never fetch them; Play for On-device AI delivers models
you own and bundle, not ones fetched from a hub at runtime.
:ferry-work is an optional module wrapping the download in a CoroutineWorker, with a
host-controlled foreground notification, WorkManager's retry and backoff, and a uniqueness guarantee
:ferry cannot provide. Nothing in :ferry depends on it.
implementation("dev.thuat:ferry-work:0.2.0")
WorkManager.getInstance(context)
.enqueueRepoDownload(repoId = "google/gemma-2-2b-it", into = filesDir, notificationId = 42)Setup, retry policy and design reasoning: docs/ferry-work.md.
:sample is a small Compose app demonstrating what Ferry refuses to do, not its downloads. A
progress bar cannot show a guarantee; only the absence of one is visible. It lists three real
HuggingFace repos plus a sabotage panel that fakes a nearly-full disk and corrupts a downloaded file
on demand, both built on seams the library already exposes.
./gradlew :sample:assembleDebug./gradlew :ferry:jvmTest:ferry's JVM target needs only a JDK; ./gradlew :ferry:iosSimulatorArm64Test additionally needs
Xcode and an iOS simulator. The rest of the repo (:ferry-work and :sample) also needs an Android
SDK with API 35, and a JDK between 17 and 21. The upper bound is Gradle 8.9's: a newer JDK fails
during script compilation with a bare IllegalArgumentException naming the JDK's own version and
nothing else.
JAVA_HOME=/path/to/jdk-21 ./gradlew :ferry:checkModelHub interface, and what
three hubs look like side by side. Read this before writing an adapter.:ferry-work setup and every design decision behind it.RepoProgress is a sealed interface and pause is unimplemented, so adding it later needs a new case,
which breaks any consumer's exhaustive when. ModelHub is likewise expected to grow a real error
taxonomy beyond a bare IOException. Both may break before 1.0.0. That is a deliberate use of what
0.x means in semver, not instability.
Apache 2.0