Kotlin Multiplatform backup into the user's own cloud: iCloud on iOS (CloudKit or iCloud Drive), the Google Drive app-data folder on Android. No server, no account on your side, no OAuth client setup on iOS.
- Why · Features · BackupKit 101 · A more advanced example
- Support matrix · Requirements · Samples · Testing
- Who's using it · Communication · Limits · Compared with
Every app with a wordbook or a journal eventually needs "a new phone should not lose my stuff". A backend for that means accounts, hosting and a privacy policy that says you hold user data. The user already pays for a cloud. BackupKit puts the files there, invisible to them, readable only by your app, with an engine that keeps the mirror correct across kills, retries and account switches.
- The user's cloud, not yours. App-private iCloud container or Drive's hidden
appDataFolder(the one WhatsApp uses). - Zero code on iOS, one tap on Android. The iCloud entitlement is the iOS setup; Android shows Google's dialog once.
- Complete-or-absent sync. Diffs, uploads the marker last, saves state after every step, resumes after a kill, refuses to merge two accounts, and refuses to delete most of a healthy backup without the user's say-so.
- Restore that survives a kill. Typed probe, write hold, resumable download with a commit boundary and per-file attempts.
- Typed errors. Seven
CloudErrorvalues, nothing platform-specific leaks out. - Small. About 1,200 lines. No DI framework, no Compose, no Firebase; iOS links no HTTP client.
// libs.versions.toml backupkit = { module = "com.vocabloot:backupkit", version = "0.3.0" }
// build.gradle.kts (shared) commonMain.dependencies { implementation(libs.backupkit) }One entitlement on iOS, one OAuth client on Android (setup), then:
// iOS (iosMain) // Android (androidMain)
val storage: CloudStorage = CloudKitStorage( val storage: CloudStorage = GoogleDriveStorage(context)
checkpointPath = "$dir/ck-checkpoint.json",
cacheDirectory = "$dir/ck-cache",
) // or ICloudStorage() for files the user may open in Files
// common
val engine = SyncEngine(
storage = storage,
stateStore = FileSyncStateStore("$dir/backupkit-state.json"),
policy = SyncPolicy(markerPath = "backup.json"),
)
val notes: ByteArray = notesJson()
val header: ByteArray = headerJson() // uploaded last: its presence means "complete"
val outcome = engine.sync(
SyncSnapshot(
listOf(
SyncEntry("notes.json", SyncSource.Bytes(notes), notes.size.toLong(), hash = sha256Hex(notes)),
SyncEntry("backup.json", SyncSource.Bytes(header), header.size.toLong(), hash = sha256Hex(header)),
),
isEmpty = notes.isEmpty(), // a fresh install never overwrites a real backup
),
)
when (outcome) {
is SyncOutcome.Synced -> showUpToDate()
is SyncOutcome.Unavailable -> showWhy(outcome.reason) // NoAccount, NeedsConsent, RestorePending
is SyncOutcome.Failed -> showStuck(outcome.error) // Offline, StorageFull, AuthRevoked, Transport, ...
}Photos and other write-once files go in as SyncSource.LocalFile(path) with hash = null: compared by size, uploaded first, never re-uploaded.
Offer a restore on first launch, then pull the files down with a commit boundary and resume after a kill:
when (val probe = engine.probe()) {
is RemoteProbe.Found -> if (askUser(parseHeader(probe.marker))) restore(probe)
RemoteProbe.NotReady -> showStillUploading()
else -> Unit
}
suspend fun restore(found: RemoteProbe.Found) {
engine.setHold(WriteHold.RestoreRunning)
val restore = RestoreEngine(engine, storage, FileRestoreRecordStore("$dir/restore.json"), placement = { group, files ->
importIntoMyModel(group, files); PlacementResult.Placed // your schema, your rules
})
val outcome = restore.start(RestorePlan(found.source, files = listOf(
RestoreFile("notes.json", toLocalPath = "$dir/notes.json", required = true, group = "meta"),
))) { p -> show("${'$'}{p.groupsDone} of ${'$'}{p.groupsTotal}") }
if (outcome !is RestoreOutcome.Failed) engine.setHold(WriteHold.None)
}Full walkthrough, holds and resume(): Restore on first launch.
| CloudKit (iOS) | iCloud Drive (iOS) | Google Drive app-data (Android) | |
|---|---|---|---|
| Transport | CloudKitStorage |
ICloudStorage |
GoogleDriveStorage |
| Auth | entitlement only | entitlement only | silent token, DriveConsent once |
| Single upload cap | 1 asset per save | container quota | none (resumable above 5 MB) |
| Verified on a real device | iPhone 16 Pro, 2026-09-07 | iPhone 16 Pro, 2026-09-05 | Pixel 7 Pro, 2026-09-07 |
CloudKit for app data the user never opens as files; iCloud Drive when the files should show in the Files app. Every row, and the differences between the transports: support matrix.
| Minimum | Built with | |
|---|---|---|
| Kotlin / Gradle / AGP | 2.3 / 9.0 / 9.0 | 2.3.20 / 9.4.1 / 9.2.1 |
| Android | minSdk 24 | compileSdk 36 |
| iOS / Xcode | 16 / 16 | iOS 26 / Xcode 26 |
Targets: android, iosArm64, iosSimulatorArm64, iosX64. Versioning and the experimental API policy: stability.
| Sample | Shows |
|---|---|
| Notes | sync with a marker, restore dialog on first launch, Android consent |
com.vocabloot:backupkit-test (same version) ships the fakes the library's own tests run on, so your sync and restore code is unit-testable with no cloud:
val storage = FakeCloudStorage(readLocal = files::read, writeLocal = files::write)
val engine = SyncEngine(storage, MemorySyncStateStore(), SyncPolicy(markerPath = "backup.json"))
storage.failPutsContaining = "photos/" // then assert the outcome and storage.putLog- Vocabloot (App Store, Google Play): wordbook, photos and doodles mirror through this exact code. BackupKit is that code, extracted; Vocabloot 1.2 is the first store build that carries it.
Works with anything that gives you bytes or a file path: SQLDelight, Room, Okio, kotlinx-serialization, your own Ktor client on Android. Using BackupKit? Open a PR and add yourself.
- Questions and ideas: Discussions.
- Bugs: Issues, with the
CloudErrorand platform. - Security: SECURITY.md, privately.
- Contributing: CONTRIBUTING.md. Conduct: CODE_OF_CONDUCT.md.
- Drive: files above 5 MB use Drive's resumable protocol in 8 MiB chunks, streamed from disk; the multipart path stays for small files.
- Drive app-data counts against the user's Drive quota (Android Auto Backup does not).
- iCloud on the simulator needs an iCloud login on the simulator.
RestoreEngineis@ExperimentalRestoreApi: its shape may still change in a minor release. Nothing Vocabloot-specific lives in it: files carry an opaque group and the app supplies aRestorePlacement.- The sample app exercises
ICloudStorageandGoogleDriveStorage;USE_CLOUDKITswitches it to CloudKit. - Not included: scheduling, encryption, restore-into-your-model, any UI, Dropbox/OneDrive (see CloudBridge for those).
Open items with workarounds: known issues.
| Android Auto Backup | Own server | CloudBridge | react-native-cloud-storage | BackupKit | |
|---|---|---|---|---|---|
| Where the data lives | Google's backup service | your servers | user's Dropbox, Drive, OneDrive, WebDAV | user's iCloud or Drive | user's iCloud or Drive |
| iOS | no | yes | yes | yes | yes, entitlement only |
| Accounts you run | none | yes | none | none | none |
| Sync engine (diff, resume, marker, holds) | opaque | yours | no, file API only | no, file API only | yes |
| Restore with commit boundary | opaque | yours | no | no | yes |
| Kotlin Multiplatform | n/a | n/a | yes | no (React Native) | yes |
Docs site: setup, the SyncEngine contract, restore, consent, errors, scheduling, recipes, FAQ, known issues, stability. API reference (Dokka). Design notes and publishing steps are in docs/ for maintainers.
Inspired by react-native-cloud-storage (the Layer 1 verbs) and by IceCream and Apple's CKSyncEngine (the engine owns the state).
Apache 2.0. Made by Vaazh Studios.
