A Kotlin Multiplatform library for resumable large-file uploads. Targets Android, iOS, and JVM. Relay handles chunking, retries, persistence, and automatic resume - the client only needs to supply a file abstraction and a resumable upload URL.
Warning
This library is under active development and not yet published. The API is unstable and subject to change.
- Automatic resume - content-based fingerprinting (first + last 256 KB) means no external ID management; Relay recognises the same file across process restarts.
- Provider-agnostic - Azure Block Blob and GCS Resumable Upload are detected automatically from the URL.
- Flow-based progress - single
Flow<RelayEvent>per upload; one-shotgetUploadState()for list screens. - Configurable concurrency - sequential (queued) or concurrent at instance creation time.
- Exponential backoff - retryable chunk failures are retried internally; URL expiry is surfaced to the client for a fresh URL.
Add the dependency to your shared module's build.gradle.kts:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.thedroiddiv:relay:<version>")
}
}
}Or with the Gradle version catalog:
# gradle/libs.versions.toml
[versions]
relay = "<version>"
[libraries]
relay = { module = "io.github.thedroiddiv:relay", version.ref = "relay" }// shared/build.gradle.kts
commonMain.dependencies {
implementation(libs.relay)
}val relay = Relay(
config = RelayConfig(
concurrencyMode = ConcurrencyMode.CONCURRENT,
maxRetries = 5,
),
httpClient = myKtorClient,
databasePath = context.getDatabasePath("relay.db").absolutePath, // Android
)// Android example
val file = object : RelayFile {
override val size: Long =
contentResolver.openFileDescriptor(uri, "r")!!.statSize
override suspend fun read(offset: Long, length: Int): ByteArray =
withContext(Dispatchers.IO) {
RandomAccessFile(
contentResolver.openFileDescriptor(uri, "r")!!.fileDescriptor, "r"
).use { raf ->
raf.seek(offset)
ByteArray(length).also { raf.read(it) }
}
}
}val handle = relay.upload(file, resumableUrl = sasUrl)
handle.events.collect { event ->
when (event) {
is RelayEvent.Progress -> showProgress(event.bytesUploaded, event.totalBytes)
is RelayEvent.Complete -> onUploadComplete(event.fileId)
is RelayEvent.UrlExpired -> {
val newUrl = api.fetchFreshUrl(fileName)
relay.resume(event.fileId, file, newUrl).events.collect(this)
}
is RelayEvent.Error -> {
if (!event.retryable) showError(event.cause)
}
}
}// Pause (e.g. app goes to background)
relay.pause(handle.fileId)
// Resume later - same file fingerprint matches the persisted record automatically
val resumeHandle = relay.upload(file, lastKnownUrl)
// Or supply a fresh URL explicitly
val resumeHandle = relay.resume(handle.fileId, file, newUrl)
// Cancel - removes all persisted state; partially uploaded blocks expire on the provider
relay.cancel(handle.fileId)class Relay(
config: RelayConfig = RelayConfig(),
httpClient: HttpClient,
databasePath: String,
)| Method | Description |
|---|---|
upload(file, resumableUrl) |
Start a new upload, or auto-resume if the fingerprint matches a prior record. Returns a cold Flow - collecting it starts the upload; cancelling pauses it. |
resume(fileId, file, newResumeUrl) |
Resume a URL_EXPIRED or PAUSED upload with a refreshed URL. |
pause(fileId) |
Pause an active upload. State is preserved; call upload() or resume() to continue. |
cancel(fileId) |
Cancel and delete all persisted state for this upload. |
getUploadState(fileId) |
One-shot query for the current persisted state. Returns null if no record exists. |
computeFileId(file) |
Compute the fingerprint without starting an upload. Useful to check if a file was already uploaded. |
getAllUploadStates() |
Flow<List<RelayUploadState>> - emits on every status change. Useful for a global uploads screen. |
Client-supplied file abstraction. Wrap your platform URI, path, or NSData into this interface.
interface RelayFile {
val size: Long
suspend fun read(offset: Long, length: Int): ByteArray
}data class RelayConfig(
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
val fingerprintSizeBytes: Int = 256 * 1024, // bytes read from each end for fingerprinting
val maxRetries: Int = 3,
val retryInitialDelayMs: Long = 1_000L,
val retryMaxDelayMs: Long = 30_000L,
val urlExpiryThresholdMs: Long = 6 * 60 * 60 * 1000L, // 6 hours
)
enum class ConcurrencyMode { SEQUENTIAL, CONCURRENT }sealed interface RelayEvent {
data class Progress(val fileId: String, val bytesUploaded: Long, val totalBytes: Long) : RelayEvent
data class Complete(val fileId: String) : RelayEvent
data class UrlExpired(val fileId: String) : RelayEvent
data class Error(val fileId: String, val cause: Throwable, val retryable: Boolean) : RelayEvent
}UrlExpired is emitted when the URL age exceeds RelayConfig.urlExpiryThresholdMs. Call resume() with a fresh URL to continue.
Error with retryable = true means internal retries are exhausted; the client may retry after a delay. retryable = false means the upload cannot proceed (e.g. 403, file not found).
data class RelayUploadState(
val fileId: String,
val resumableUrl: String,
val urlFetchedAtMs: Long,
val provider: RelayProvider,
val totalBytes: Long,
val bytesUploaded: Long,
val status: RelayStatus,
)
enum class RelayProvider { AZURE, GCS }
enum class RelayStatus { PENDING, IN_PROGRESS, PAUSED, URL_EXPIRED, COMPLETED, FAILED }Provider detection is automatic based on the URL.
| Provider | Detection | Chunk size | Notes |
|---|---|---|---|
| Azure Block Blob | blob.core.windows.net in URL |
4 MB | PUT block + PUT blocklist; already-committed blocks are skipped on resume |
| GCS Resumable Upload | All other URLs | 8 MB | Content-Range streaming; server state is authoritative for resume offset |
Relay computes a stable fileId from file content using SHA-256 of the first and last fingerprintSizeBytes of the file (default 256 KB each end). For files smaller than 2 × fingerprintSizeBytes, the full file is read. This means:
- No external ID tracking is required.
- The same physical file always maps to the same record across process restarts.
- Truncated or padded variants of the same content are treated as distinct files.
In SEQUENTIAL mode, uploads are queued - upload() suspends until the running upload completes, is paused, or is cancelled.
In CONCURRENT mode, each upload runs in its own coroutine inside Relay's internal CoroutineScope (backed by SupervisorJob). A failure in one upload does not cancel others.
Copyright 2024 thedroiddiv
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0