Skip to content
thedroiddivPublic

About

A Kotlin Multiplatform library for resumable large-file uploads. Supports Azure Blob Storage and Google Cloud Storage with automatic resume, content-based fingerprinting, and Flow-based progress.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Relay

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.

Features

  • 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-shot getUploadState() 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.

Installation

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)
}

Quick Start

1. Create a Relay instance (once, e.g. in your DI graph)

val relay = Relay(
    config = RelayConfig(
        concurrencyMode = ConcurrencyMode.CONCURRENT,
        maxRetries = 5,
    ),
    httpClient = myKtorClient,
    databasePath = context.getDatabasePath("relay.db").absolutePath, // Android
)

2. Implement RelayFile for your platform

// 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) }
            }
        }
}

3. Upload (or auto-resume)

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)
        }
    }
}

4. Pause, resume, and cancel

// 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)

API Reference

Relay

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.

RelayFile

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
}

RelayConfig

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 }

RelayEvent

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).


RelayUploadState

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 }

Supported Providers

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

File Fingerprinting

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.

Concurrency

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.


License

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

About

A Kotlin Multiplatform library for resumable large-file uploads. Supports Azure Blob Storage and Google Cloud Storage with automatic resume, content-based fingerprinting, and Flow-based progress.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages