Skip to content

Repository files navigation

Ferry

Maven Central License

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" />.

How a download works

Ferry resolves a manifest from the hub, refuses up front if the disk cannot hold it, downloads each file into a staging directory where a .part resumes via Range and is renamed only after its sha256 verifies, then commits the whole repo with one atomic rename

Guarantees

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.

Why this exists

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.

Backgrounding

: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 app

: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

Building

./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:check

Docs

  • Hubs. HuggingFace and range-request field notes, the ModelHub interface, and what three hubs look like side by side. Read this before writing an adapter.
  • Backgrounding. :ferry-work setup and every design decision behind it.
  • Known limitations. What is open, what is closed, and why.
  • Releasing. Maintainer-only.
  • Changelog. What changed release to release, including migration notes for breaking changes.

0.x

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.

License

Apache 2.0

About

Downloads AI model repositories to Android and iOS devices, and refuses to do it badly

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages