:ferry-work is an optional module that wraps RepoDownloader.download in a CoroutineWorker,
with a host-controlled foreground notification, WorkManager's own retry and backoff, and a
uniqueness guarantee :ferry itself cannot provide. It depends on :ferry, and nothing in :ferry
depends on it. A host with no interest in WorkManager takes only :ferry and never notices
:ferry-work exists.
dependencies {
implementation("dev.thuat:ferry-work:0.2.0")
}Your notification, your channel, your wording, registered once via WorkManager's own factory hook:
class App : Application(), Configuration.Provider {
override val workManagerConfiguration = Configuration.Builder()
.setWorkerFactory(
RepoDownloadWorkerFactory { repoId, progress ->
// progress is null before the first update arrives
buildNotification(repoId, progress)
},
)
.build()
}Then enqueue:
WorkManager.getInstance(context)
.enqueueRepoDownload(repoId = "google/gemma-2-2b-it", into = filesDir, notificationId = 42)Ferry moves bytes. It never decides what your users read while it does.
The second call keeps the running work instead of replacing it.
Replacing it would cancel the in-flight download call, and coroutine cancellation is cooperative,
stopping at the next checkpoint rather than instantly. A replacement's own download call could
therefore start writing into the same staging directory while the cancelled one's last write is still
landing. That is the same concurrent-download hazard download's own KDoc already warns against for
two ordinary calls, self-inflicted here by cancelling one enqueue's work out from under the request
about to replace it. KEEP avoids it by never starting the second call at all while the first is
still running.
:ferry's own checkEmbeddable Gradle task fails the build the moment androidx.work appears on
its classpath, and it is wired into check, so it is enforced rather than merely intended.
The reason is the README's "Why this exists" table. Alibaba's MNN backgrounds downloads with a
plain foreground Service; Google's AI Edge Gallery backgrounds them with a CoroutineWorker. A
:ferry that required WorkManager would rule MNN's approach out by construction, which is the exact
failure this project exists to avoid for a hub, applied one layer up to a backgrounding strategy.
Splitting backgrounding into its own module, rather than gating it behind a feature flag inside
:ferry, is what makes the exclusion real: a flag can still be compiled in and inspected on the
classpath, but a dependency that is never declared cannot be.
Full reasoning lives in RepoDownloadWorker's own KDoc; this is the shape of each choice.
- Input and output.
repoId, an absoluteintopath and a notification id go in asandroidx.work.Data, primitives only. On success, the committed directory's own absolute path comes out, not recomputed from the inputs by this module. On failure, onlyInsufficientSpaceExceptionsurvives in detail (a reason code, its message, and itsSpaceReport's numbers). Every other failure loses its exception type and carries noDatawhile it is still retrying. See Retry for when it stops. - Retry.
InsufficientSpaceExceptionfails outright: a full device is exactly as full on the next attempt. Everything else retries, including aVerificationException, becauseRepoDownloaderexposes no richer taxonomy than these two named exceptions plus a bareIOExceptionfor everything else, so a dropped connection and a permanently-corrupt file are not distinguishable by type from outside:ferry. That retry is bounded, not indefinite: exponential backoff makes attempts less frequent, never fewer, so a case that will in fact never pass needs an actual ceiling.RepoDownloadWorker.MAX_RETRY_ATTEMPTS(5) is that ceiling, read off WorkManager's ownrunAttemptCount. Past it, the worker fails withREASON_RETRIES_EXHAUSTEDin the sameKEY_FAILURE_REASONfieldInsufficientSpaceExceptionuses, rather than a parallel one. - Progress.
RepoProgress.Downloadingfires once per read buffer.RepoDownloadThrottle, a direct port of:sample's ownDownloadingThrottlerather than a shared dependency (the only module both could share is:ferry), gates bothsetProgressand the notification update to at most once a second, always letting a file's last byte through. - Uniqueness.
enqueueRepoDownloadwrapsWorkManager.enqueueUniqueWorkwithExistingWorkPolicy.KEEP, keyed by repo id: a second enqueue for a repo already running is dropped rather than cancelling and restarting it, which would forfeit whatever had already downloaded. This closesknown-limitations.md's concurrent-download entry within WorkManager. A host callingRepoDownloaderdirectly, outside this enqueue path, is still responsible for serialising itself the way that entry has always said. - Foreground. The notification's text, icon and actions are supplied by the host through
RepoDownloadNotifications, never hardcoded by Ferry, the same reasoning that keeps:ferryitself free of a UI framework.:ferry-work's own manifest declaresandroid.permission.FOREGROUND_SERVICE_DATA_SYNCand overrides WorkManager'sSystemForegroundServicewithandroid:foregroundServiceType="dataSync", verified directly against work-runtime 2.11.2's own merged manifest (which declares neither), so a host does not need to know that internal class name exists at all.