Skip to content

Latest commit

 

History

History
182 lines (135 loc) · 7.9 KB

README.md

File metadata and controls

182 lines (135 loc) · 7.9 KB

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