
Backs app data into the user's own cloud (app-private storage), no servers or accounts, diff-based sync, resumable restores, commit boundaries, and typed errors.
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.
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.
appDataFolder (the one WhatsApp uses).CloudError values, nothing platform-specific leaks out.// 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.putLogWorks 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.
CloudError and platform.RestoreEngine is @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 a RestorePlacement.ICloudStorage and GoogleDriveStorage; USE_CLOUDKIT switches it to CloudKit.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.
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.
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.
appDataFolder (the one WhatsApp uses).CloudError values, nothing platform-specific leaks out.// 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.putLogWorks 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.
CloudError and platform.RestoreEngine is @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 a RestorePlacement.ICloudStorage and GoogleDriveStorage; USE_CLOUDKIT switches it to CloudKit.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.