
Calendar SDK combining local database, managers and repositories with a Rust-powered CalDAV bridge (UniFFI/Gobley), iCalendar parsing, extra CalDAV property support, and async cancellable network calls.
multiplatform-calendar/
├── Core/ # Public KMP library (domain, Room DB, repositories, managers, Apple SDK)
│ ├── src/commonMain/ # Cross-platform: domain, Room DB, repositories, DI graph contracts/mappers
│ ├── src/androidMain/ # Android Room database provider (via Metro DI)
│ ├── src/appleMain/ # CalendarSDK + CalendarSDKProvider (Apple public DI graph)
│ └── src/commonTest/ # Shared unit tests
├── kmpdav/ # Internal KMP bridge module (Rust/UniFFI + remote CalDAV layer)
│ ├── src/commonMain/ # RustCaldavBridge, CaldavClientModule, remote models, remote client interface
│ ├── rust/caldav_bridge/ # Rust crate: CalDAV operations via fast-dav-rs + icalendar
│ └── build.gradle.kts # Bridge module build (Gobley/UniFFI, Metro)
├── build.gradle.kts # Root aggregator (no sources)
├── Core/build.gradle.kts # Public library build (SKIE, Metro, XCFramework)
├── buildRelease # Script to build & zip KmpCalendar.xcframework for iOS/macOS release
└── buildRust # Script for standalone Rust compilation (optional, Gradle handles it)
| Module | Purpose |
|---|---|
| Core | Public API: domain models, Room database, DAOs, repositories, managers, Apple CalendarSDK
|
| kmpdav | Internal bridge: Rust/UniFFI CalDAV bridge, remote CalDAV models/client, CaldavClientModule
|
The KmpCalendar.xcframework is produced by the Core module. :kmpdav is a plain implementation dependency
(not exported): the public Apple API only exposes Core-owned types — e.g. credentials are passed as the Core
DavCredentials (mapped to the internal :kmpdav DavAccount at the repository boundary). The only :kmpdav symbol
left in the generated header is an empty CaldavClientModule marker protocol that CalendarSDK must conform to for
DI (see the DI note below); no :kmpdav data type is exposed.
Apple consumers import KmpCalendar and access the SDK through:
import KmpCalendar
let sdk = CalendarSDKProvider.shared.sdk
sdk.accountManager.initAccount(...)
sdk.calendarManager.observeCalendars(...)AppGraph (in the Android app) is the @DependencyGraph. Core contributes shared graph accessors
(CalendarCoreGraph) plus AndroidDatabaseModule and DatabaseModule. The :kmpdav module contributes CaldavClientModule.CalendarSDK (in Core/appleMain) is the public @DependencyGraph. It provides the Apple Room database,
inherits CalendarCoreGraph explicitly to export accountManager / calendarManager, and inherits :kmpdav's
CaldavClientModule explicitly to obtain the CalDAV bridge binding. It is accessed via CalendarSDKProvider.shared.sdk.Before you begin, ensure you have met the following requirements:
ensureNdkVersion Gradle plugin automatically manages the NDK version — necessary because AGP doesn't handle NDK updates
for KMP projects. It will download the correct version if the Android
SDK Command-line Tools are installed, or reuse a newer installed NDK.rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android \
aarch64-apple-ios aarch64-apple-ios-sim aarch64-apple-darwinThe kmpdav/rust/caldav_bridge crate provides CalDAV operations (discover calendars, CRUD events)
via fast-dav-rs.
iCalendar data is parsed into typed fields using the icalendar crate, and extra WebDAV
collection properties (privileges, owner, color) via roxmltree (see Extra CalDAV properties below).
The Rust → Kotlin/Swift bridge is handled automatically by Gobley + UniFFI — no manual JNI, cinterop, or JSON serialization needed.
Kotlin/Swift ←──UniFFI bindings──→ Rust lib.rs → fast-dav-rs (CalDAV) → icalendar (parsing)
Rust compilation and binding generation are integrated into the Gradle build via the dev.gobley.cargo and dev.gobley.uniffi
plugins. No separate build step is required — just run ./gradlew assembleDebug.
All network exports are async fn on the Rust side, exported with
#[uniffi::export(async_runtime = "tokio")]. UniFFI generates Kotlin suspend fun
bindings that carry @Throws(CaldavException, CancellationException), so cancelling
the calling coroutine drops the Rust future, which drops the underlying reqwest
request.
discoverCalendars, getEvents, getEventsInRange, syncCollection,
getEventsByUrls) are fully cancellable and safe to cancel at any time.updateCalendar, createEvent, updateEvent, deleteEvent) are best-effort cancellable:
cancelling after the request has been dispatched to the server leaves the outcome
undefined (the server may or may not have applied the change). Callers must
re-sync to reconcile.patchEventIcs, buildEventIcs) stay synchronous and are
wrapped in withContext(Dispatchers.Default) inside RustCaldavBridge so they
never block the caller's thread.fast-dav-rs only surfaces a fixed subset of collection properties. When we need others — the current-user-privilege-set
(RFC 3744, mapped to a CalendarAccessLevel), the DAV:owner, or the Apple calendar-color — the crate issues its own
Depth: 1 PROPFIND and parses the multistatus with roxmltree (see
rust/caldav_bridge/src/props.rs).
Parsing matches on local names so it is agnostic to the server's namespace prefix, and is best-effort: a missing or
unsupported property never breaks calendar discovery. To fetch a new property, add it to PROPS_BODY and to CollectionProps.
The Rust artifacts are huge in debug and small in release — always compare like-for-like:
| Artifact | Debug | Release |
|---|---|---|
Android .so (per ABI, shipped) |
~66–77 MB | ~4.8 MB |
Apple .a (per slice) |
~140 MB | ~14 MB |
The .a static archive is never shipped: only the linked, stripped .so (Android) or the framework binary (Apple) goes
into the app. The release profile (lto, opt-level = "s", strip) is configured in rust/caldav_bridge/Cargo.toml.
⚠️ Do not setpanic = "abort": UniFFI relies on catching Rust panics to convert them into FFI errors; aborting would crash the app instead.
Profile selection per consumer (Gobley):
Android — follows the AGP build type automatically: assembleDebug → Rust dev profile, assembleRelease /
bundleRelease → Rust release profile. Nothing to configure.
Apple / Kotlin-Native — Debug. So assembleKmpCalendarReleaseXCFramework would
otherwise link the ~140 MB debug .a into the release XCFramework (a ~250 MB zip). To force a release native Rust
build, pass -PrustNativeRelease=true (already wired into buildRelease):
./gradlew :Core:assembleKmpCalendarReleaseXCFramework -PrustNativeRelease=trueThis is opt-in so day-to-day Apple builds keep a debug Rust (faster rebuilds, native debug symbols).
Static framework keeps DWARF — the Apple framework is static (isStatic = true), i.e. an unlinked ar archive, so
both the Kotlin and Rust objects keep their debug info (__DWARF) even in release (Cargo's strip only applies to linked
binaries like the Android .so, not to static .a). That is ~30% of dead weight per slice (e.g. iosArm64: 31 MB → 22 MB).
buildRelease therefore runs strip -S (+ ranlib) on each XCFramework slice before zipping; strip -S removes the debug
sections while keeping the global symbols required for linking.
Build the app in debug but Rust in release (small/optimized native lib in a debug app): repoint the debug Cargo
variant to the release profile in kmpdav/build.gradle.kts:
import gobley.gradle.cargo.profiles.CargoProfile
cargo {
packageDirectory = layout.projectDirectory.dir("rust/caldav_bridge")
debug.profile = CargoProfile.Release
}# Build the KmpCalendar XCFramework (iOS/macOS) — release Rust (see "Rust build profiles")
./gradlew :Core:assembleKmpCalendarReleaseXCFramework -PrustNativeRelease=true
# Build & zip for iOS release (updates `Package.swift` checksums when the file exists)
./buildRelease <version>
# Build Android library (debug)
./gradlew assembleDebug
# Run unit tests
./gradlew :Core:allTests
# Clean
./gradlew cleanIf you see a bug or an enhancement point, feel free to create an issue, so that we can discuss it. Once approved, we or you ( depending on the priority of the bug/improvement) will take care of the issue and apply a merge request. Please, don't do a merge request before creating an issue.
This project is under GPLv3 license. See the LICENSE file for more details.
multiplatform-calendar/
├── Core/ # Public KMP library (domain, Room DB, repositories, managers, Apple SDK)
│ ├── src/commonMain/ # Cross-platform: domain, Room DB, repositories, DI graph contracts/mappers
│ ├── src/androidMain/ # Android Room database provider (via Metro DI)
│ ├── src/appleMain/ # CalendarSDK + CalendarSDKProvider (Apple public DI graph)
│ └── src/commonTest/ # Shared unit tests
├── kmpdav/ # Internal KMP bridge module (Rust/UniFFI + remote CalDAV layer)
│ ├── src/commonMain/ # RustCaldavBridge, CaldavClientModule, remote models, remote client interface
│ ├── rust/caldav_bridge/ # Rust crate: CalDAV operations via fast-dav-rs + icalendar
│ └── build.gradle.kts # Bridge module build (Gobley/UniFFI, Metro)
├── build.gradle.kts # Root aggregator (no sources)
├── Core/build.gradle.kts # Public library build (SKIE, Metro, XCFramework)
├── buildRelease # Script to build & zip KmpCalendar.xcframework for iOS/macOS release
└── buildRust # Script for standalone Rust compilation (optional, Gradle handles it)
| Module | Purpose |
|---|---|
| Core | Public API: domain models, Room database, DAOs, repositories, managers, Apple CalendarSDK
|
| kmpdav | Internal bridge: Rust/UniFFI CalDAV bridge, remote CalDAV models/client, CaldavClientModule
|
The KmpCalendar.xcframework is produced by the Core module. :kmpdav is a plain implementation dependency
(not exported): the public Apple API only exposes Core-owned types — e.g. credentials are passed as the Core
DavCredentials (mapped to the internal :kmpdav DavAccount at the repository boundary). The only :kmpdav symbol
left in the generated header is an empty CaldavClientModule marker protocol that CalendarSDK must conform to for
DI (see the DI note below); no :kmpdav data type is exposed.
Apple consumers import KmpCalendar and access the SDK through:
import KmpCalendar
let sdk = CalendarSDKProvider.shared.sdk
sdk.accountManager.initAccount(...)
sdk.calendarManager.observeCalendars(...)AppGraph (in the Android app) is the @DependencyGraph. Core contributes shared graph accessors
(CalendarCoreGraph) plus AndroidDatabaseModule and DatabaseModule. The :kmpdav module contributes CaldavClientModule.CalendarSDK (in Core/appleMain) is the public @DependencyGraph. It provides the Apple Room database,
inherits CalendarCoreGraph explicitly to export accountManager / calendarManager, and inherits :kmpdav's
CaldavClientModule explicitly to obtain the CalDAV bridge binding. It is accessed via CalendarSDKProvider.shared.sdk.Before you begin, ensure you have met the following requirements:
ensureNdkVersion Gradle plugin automatically manages the NDK version — necessary because AGP doesn't handle NDK updates
for KMP projects. It will download the correct version if the Android
SDK Command-line Tools are installed, or reuse a newer installed NDK.rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android \
aarch64-apple-ios aarch64-apple-ios-sim aarch64-apple-darwinThe kmpdav/rust/caldav_bridge crate provides CalDAV operations (discover calendars, CRUD events)
via fast-dav-rs.
iCalendar data is parsed into typed fields using the icalendar crate, and extra WebDAV
collection properties (privileges, owner, color) via roxmltree (see Extra CalDAV properties below).
The Rust → Kotlin/Swift bridge is handled automatically by Gobley + UniFFI — no manual JNI, cinterop, or JSON serialization needed.
Kotlin/Swift ←──UniFFI bindings──→ Rust lib.rs → fast-dav-rs (CalDAV) → icalendar (parsing)
Rust compilation and binding generation are integrated into the Gradle build via the dev.gobley.cargo and dev.gobley.uniffi
plugins. No separate build step is required — just run ./gradlew assembleDebug.
All network exports are async fn on the Rust side, exported with
#[uniffi::export(async_runtime = "tokio")]. UniFFI generates Kotlin suspend fun
bindings that carry @Throws(CaldavException, CancellationException), so cancelling
the calling coroutine drops the Rust future, which drops the underlying reqwest
request.
discoverCalendars, getEvents, getEventsInRange, syncCollection,
getEventsByUrls) are fully cancellable and safe to cancel at any time.updateCalendar, createEvent, updateEvent, deleteEvent) are best-effort cancellable:
cancelling after the request has been dispatched to the server leaves the outcome
undefined (the server may or may not have applied the change). Callers must
re-sync to reconcile.patchEventIcs, buildEventIcs) stay synchronous and are
wrapped in withContext(Dispatchers.Default) inside RustCaldavBridge so they
never block the caller's thread.fast-dav-rs only surfaces a fixed subset of collection properties. When we need others — the current-user-privilege-set
(RFC 3744, mapped to a CalendarAccessLevel), the DAV:owner, or the Apple calendar-color — the crate issues its own
Depth: 1 PROPFIND and parses the multistatus with roxmltree (see
rust/caldav_bridge/src/props.rs).
Parsing matches on local names so it is agnostic to the server's namespace prefix, and is best-effort: a missing or
unsupported property never breaks calendar discovery. To fetch a new property, add it to PROPS_BODY and to CollectionProps.
The Rust artifacts are huge in debug and small in release — always compare like-for-like:
| Artifact | Debug | Release |
|---|---|---|
Android .so (per ABI, shipped) |
~66–77 MB | ~4.8 MB |
Apple .a (per slice) |
~140 MB | ~14 MB |
The .a static archive is never shipped: only the linked, stripped .so (Android) or the framework binary (Apple) goes
into the app. The release profile (lto, opt-level = "s", strip) is configured in rust/caldav_bridge/Cargo.toml.
⚠️ Do not setpanic = "abort": UniFFI relies on catching Rust panics to convert them into FFI errors; aborting would crash the app instead.
Profile selection per consumer (Gobley):
Android — follows the AGP build type automatically: assembleDebug → Rust dev profile, assembleRelease /
bundleRelease → Rust release profile. Nothing to configure.
Apple / Kotlin-Native — Debug. So assembleKmpCalendarReleaseXCFramework would
otherwise link the ~140 MB debug .a into the release XCFramework (a ~250 MB zip). To force a release native Rust
build, pass -PrustNativeRelease=true (already wired into buildRelease):
./gradlew :Core:assembleKmpCalendarReleaseXCFramework -PrustNativeRelease=trueThis is opt-in so day-to-day Apple builds keep a debug Rust (faster rebuilds, native debug symbols).
Static framework keeps DWARF — the Apple framework is static (isStatic = true), i.e. an unlinked ar archive, so
both the Kotlin and Rust objects keep their debug info (__DWARF) even in release (Cargo's strip only applies to linked
binaries like the Android .so, not to static .a). That is ~30% of dead weight per slice (e.g. iosArm64: 31 MB → 22 MB).
buildRelease therefore runs strip -S (+ ranlib) on each XCFramework slice before zipping; strip -S removes the debug
sections while keeping the global symbols required for linking.
Build the app in debug but Rust in release (small/optimized native lib in a debug app): repoint the debug Cargo
variant to the release profile in kmpdav/build.gradle.kts:
import gobley.gradle.cargo.profiles.CargoProfile
cargo {
packageDirectory = layout.projectDirectory.dir("rust/caldav_bridge")
debug.profile = CargoProfile.Release
}# Build the KmpCalendar XCFramework (iOS/macOS) — release Rust (see "Rust build profiles")
./gradlew :Core:assembleKmpCalendarReleaseXCFramework -PrustNativeRelease=true
# Build & zip for iOS release (updates `Package.swift` checksums when the file exists)
./buildRelease <version>
# Build Android library (debug)
./gradlew assembleDebug
# Run unit tests
./gradlew :Core:allTests
# Clean
./gradlew cleanIf you see a bug or an enhancement point, feel free to create an issue, so that we can discuss it. Once approved, we or you ( depending on the priority of the bug/improvement) will take care of the issue and apply a merge request. Please, don't do a merge request before creating an issue.
This project is under GPLv3 license. See the LICENSE file for more details.