Uploader
0.2.0indexedChunked multipart uploads to cloud storage with pause/resume, retry and persistent upload state across restarts. Enqueued background engine, per-chunk progress stream, and pluggable HTTP callbacks.
Chunked multipart uploads to cloud storage with pause/resume, retry and persistent upload state across restarts. Enqueued background engine, per-chunk progress stream, and pluggable HTTP callbacks.
Kotlin Multiplatform library for chunked file uploads to cloud storage (Drive-style multipart APIs), with pause/resume, retry, and persistent upload state across app restarts.
Repository: github.com/mohamadkaramidarabi/uploader
Licensed under the Apache License 2.0 (free and open source).
You bring your own HTTP layer. The library calls your startUpload, putChunk, completeUpload, and cancelUpload callbacks — it does not hard-code a specific REST API.
In your Kotlin Multiplatform module, add the main artifact to commonMain:
// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.mohamadkaramidarabi:uploader-api:0.1.8")
}
}
}
uploader-api pulls in uploader-common transitively. Room/IndexedDB implementations are bundled inside the platform-specific parts of uploader-api — you do not need to add uploader-database unless you extend the cache layer yourself.
Create a single IUploader instance at app startup (e.g. in DI or Application.onCreate). The library uses a singleton internally — call IUploader.init(...) only once.
The uploader runs a background engine that moves each file through these stages:
flowchart TD
enqueue["upload(requests)"] --> inQueue[IN_QUEUE]
inQueue --> preparing[PREPARING]
preparing --> prepared[PREPARED]
prepared --> starting[STARTING]
starting --> started[STARTED]
started --> uploading[UPLOADING]
uploading --> allPutDone[ALL_PUT_DONE]
allPutDone --> completing[COMPLETING]
completing --> success[SUCCESS]
uploading --> paused[PAUSED]
paused -->|"resume()"| uploading
uploading --> failed[FAILED]
uploading --> canceled[CANCELED]
Step by step:
IUploader.init(...)StartUploadResponsedata class StartUploadResponse(
val uploadId: String,
val key: String,
val chunkSize: Long,
val links: List<String>, // signed PUT URLs, one per chunk
)
UploadRequestdata class UploadRequest(
val fileName: FileName, // display name
val filePath: FilePath, // platform-specific path (see Platform setup)
val fileSize: FileSize, // total bytes
val folderId: FolderId?, // destination folder on cloud (optional)
val cloudPath: CloudPath, // logical cloud path
versionGroup: String?,
metaData: String?,
)
Use the extension helpers to build typed values:
"file.pdf".fileName
"/storage/emulated/0/Download/file.pdf".filePath
contentUri.toString().filePath // Android
"web-file://1".filePath // Web
1_048_576L.fileSize
"/drive/folder".cloudPath
uploader.getAllUploadInfos().collect { uploads ->
uploads.forEach { info ->
val total = info.chunkCount?.value ?: return@forEach
val done = info.links.count { it.state == States.Link.State.SUCCESS }
println("${info.name.value}: $done / $total chunks")
}
}
File reader context — pass Application or Activity context:
IUploader.init(
// ...
fileReaderContext = applicationContext,
)
File paths — use content Uri strings (recommended) or absolute filesystem paths:
UploadRequest(
filePath = contentUri.toString().filePath,
// ...
)
Grant persistable read permission when picking files (see composeApp FilePicker.android.kt).
Notifications (optional) — enabled by default on Android. Wire permission request in your Activity:
import ir.sharif.drive.uploader.api.ensureUploadNotificationsEnabled
import ir.sharif.drive.uploader.upload.UploadNotificationPermissionHost
import ir.sharif.drive.uploader.upload.UploadNotificationPermissions
// In Activity.onCreate:
UploadNotificationPermissionHost.activity = this
UploadNotificationPermissionHost.launcher = permissionLauncher
UploadNotificationPermissions.requestIfNeeded(this, permissionLauncher)
// Before starting uploads:
ensureUploadNotificationsEnabled()
File reader context — pass any value (Any()); paths are read directly from disk:
fileReaderContext = Any()
UploadRequest(
filePath = "/Users/me/Documents/file.zip".filePath,
// ...
)
Browsers do not expose real filesystem paths. Register picked File objects and use the returned path:
import ir.sharif.drive.uploader.source.file.WebFileRegistry
val path = WebFileRegistry.register(browserFile) // returns "web-file://1"
UploadRequest(
filePath = path.filePath,
fileName = browserFile.name.fileName,
fileSize = browserFile.browserSize().fileSize,
// ...
)
WebFileRegistry persists blobs in IndexedDB so uploads can resume after a page refresh.
File reader context — pass Any():
fileReaderContext = Any()
Pass a native filesystem path and Any() as context:
fileReaderContext = Any()
UploadRequest(
filePath = nativePath.filePath,
// ...
)
UploadInfo.State)Link.State)This mirrors the sample app in composeApp. Map your HTTP DTOs to the library models:
val uploader = IUploader.init(
startUpload = { size, metaData ->
response = uploadApi.startUpload(size)
StartUploadResponse(
uploadId = response.uploadId,
key = response.key,
chunkSize = response.chunkSize,
links = response.signedUrls,
)
},
putChunk = { url, chunkData, contentLength ->
uploadApi.putChunk(url, chunkData, contentLength) ?:
},
completeUpload = { request ->
uploadApi.completeUpload(request)
},
cancelUpload = { },
fileReaderContext = applicationContext,
)
uploader.upload(
listOf(
UploadRequest(
fileName = .fileName,
filePath = uri.toString().filePath,
fileSize = fileSize.fileSize,
folderId = ,
cloudPath = .cloudPath,
versionGroup = ,
metaData = ,
),
),
)
lifecycleScope.launch { uploader.pause(uploadId) }
lifecycleScope.launch { uploader.resume(uploadId) }
lifecycleScope.launch { uploader.cancel(uploadId) }
lifecycleScope.launch { uploader.retry(uploadId) }
This repository includes full demo apps. Use them as reference implementations:
Key reference files:
composeApp/.../di/AppModule.ktcomposeApp/.../main/MainViewModel.ktAndroid
./gradlew :androidApp:assembleDebug # macOS/Linux
.\gradlew.bat :androidApp:assembleDebug # Windows
Desktop (JVM)
./gradlew :desktopApp:run
Web (Wasm — recommended)
./gradlew :webApp:wasmJsBrowserDevelopmentRun
Web (JS — older browsers)
./gradlew :webApp:jsBrowserDevelopmentRun
iOS — open iosApp in Xcode or use the IDE run configuration.
./gradlew publishAllModules
Requires Sonatype and GPG credentials in ~/.gradle/gradle.properties or as ORG_GRADLE_PROJECT_* environment variables.
Override version:
./gradlew publishAllModules -Pversion=0.1.9
Push a version tag to trigger publishing:
git tag v0.1.9
git push origin v0.1.9
Workflow: .github/workflows/publish.yml
Required GitHub secrets: MAVEN_CENTRAL_USERNAME, MAVEN_CENTRAL_PASSWORD, SIGNING_KEY, SIGNING_KEY_ID, SIGNING_KEY_PASSWORD.
pause, resume, cancel, and retry.Flow.| Target | File reading | Persistence |
|---|
| Android | Content Uri or filesystem path | Room |
| JVM (Desktop) | Filesystem path | Room |
| iOS | Native file path | In-memory (via cache layer) |
| JS / Wasm (Web) | web-file:// paths via WebFileRegistry | IndexedDB |
import ir.sharif.drive.uploader.api.IUploader
import ir.sharif.drive.uploader.models.*
import ir.sharif.drive.uploader.models.CloudPath.Companion.cloudPath
import ir.sharif.drive.uploader.models.FileName.Companion.fileName
import ir.sharif.drive.uploader.models.FilePath.Companion.filePath
import ir.sharif.drive.uploader.models.FileSize.Companion.fileSize
// 1. Initialize (once)
val uploader = IUploader.init(
startUpload = { size, metaData ->
// Call your backend → return upload id, key, chunk size, signed URLs
myApi.startUpload(size, metaData)
},
putChunk = { signedUrl, chunkData, contentLength ->
// PUT chunk to signed URL → return ETag (without quotes)
myApi.putChunk(signedUrl, chunkData, contentLength)
},
completeUpload = { request ->
// Tell your backend all parts are uploaded
myApi.completeUpload(request)
},
cancelUpload = { uploadInfo ->
// Optional: notify backend to abort multipart upload
myApi.cancelUpload(uploadInfo)
},
fileReaderContext = platformContext, // see Platform setup below
)
// 2. Enqueue files
uploader.upload(
listOf(
UploadRequest(
fileName = "photo.jpg".fileName,
filePath = "/path/or/uri".filePath,
fileSize = 5_242_880L.fileSize,
folderId = null,
cloudPath = "/uploads".cloudPath,
versionGroup = null,
metaData = null,
),
),
)
// 3. Observe uploads
uploader.getAllUploadInfos().collect { uploads ->
uploads.forEach { info ->
println("${info.name.value}: ${info.state}")
}
}
upload() — saves UploadRequest items to the local cache (IN_QUEUE).startUpload callback — your backend returns uploadId, key, chunkSize, and a list of signed URLs (one per chunk).putChunk callback — for each chunk, the library reads bytes from the local file and PUTs them to the signed URL. You return the ETag from the response.completeUpload callback — after all chunks succeed, the library calls your backend with part numbers and ETags to finalize the multipart upload.| Callback | When it runs | What you return / do |
|---|
startUpload(size, metaData) | Before first chunk | StartUploadResponse(uploadId, key, chunkSize, links) |
putChunk(url, data, contentLength) | For each chunk | ETag string from storage provider |
completeUpload(request) | After all chunks uploaded | Finalize on your backend |
cancelUpload(uploadInfo) | On cancel() | Abort remote upload (optional) |
fileReaderContext | At init | Platform context for reading files (see below) |
| Method | Description |
|---|
upload(requests) | Enqueue one or more files |
getAllUploadInfos() | Flow<List<UploadInfo>> — all uploads and states |
getUploadingByState(state) | Filter uploads by state |
pause(id) | Pause an active upload |
resume(id) | Resume a paused upload |
cancel(id) | Cancel upload and call cancelUpload |
retry(id) | Clear chunks and re-queue from scratch |
deleteAll() | Remove all uploads from cache |
| State | Meaning |
|---|
IN_QUEUE | Waiting to start |
PREPARING / PREPARED | Internal preparation |
STARTING / STARTED | Calling startUpload on backend |
UPLOADING | Chunks being uploaded |
PAUSED | Paused by user |
ALL_PUT_DONE | All chunks uploaded |
COMPLETING | Calling completeUpload |
SUCCESS | Finished |
FAILED | Error (chunk or network) |
CANCELED | Canceled by user |
| State | Meaning |
|---|
IN_QUEUE | Waiting to upload |
RUNNING | Upload in progress |
SUCCESS | Chunk uploaded (ETag saved) |
FAILED | Chunk failed |
PAUSED | Paused with upload |
| Module | Role |
|---|
api, common, cache | Published libraries |
composeApp | Shared demo UI + Koin DI + network layer |
androidApp | Android entry point |
desktopApp | Desktop (JVM) entry point |
webApp | Web (JS + Wasm) entry point |
iosApp | iOS entry point |
composeApp/.../main/FilePicker.web.kt| Artifact | Module |
|---|
uploader-api | :api |
uploader-common | :common |
uploader-cache-api | :cache:cache-api |
uploader-database | :cache:database |
Surfaced from shared tags and platforms — no rankings paid for.