Built for Vocabloot, the photo vocabulary app for iOS and Android. Part of the Vocabloot open-source libraries.
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, and every Vocabloot store build since 1.2 runs on it, restore included, on the App Store and Google Play.
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: it runs on real devices in Vocabloot's store builds, but 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.
