Skip to content

BackupKit

BackupKit: your app backs up into the user's own Google Drive or iCloud, no server

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.

Android iOS Maven Central CI Kotlin License Docs Discussions

Why

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.

Features

  • 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 CloudError values, nothing platform-specific leaks out.
  • Small. About 1,200 lines. No DI framework, no Compose, no Firebase; iOS links no HTTP client.

BackupKit 101

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

A more advanced example

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.

Support matrix

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.

Requirements

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.

Samples

Sample Shows
Notes sync with a marker, restore dialog on first launch, Android consent

Testing

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

Who's using it

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

Communication

Limits

  • 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.
  • 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.
  • The sample app exercises ICloudStorage and GoogleDriveStorage; USE_CLOUDKIT switches 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.

Compared with

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

Documentation

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

License

Apache 2.0. Made by Vaazh Studios.

About

Kotlin Multiplatform backup into the user's own cloud: iCloud Drive on iOS, Google Drive app-data on Android. No server, no accounts.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages