multiplatform-calendar/
├── Account/ # Shared identity types (AccountId, AccessToken)
│ └── build.gradle.kts # Account module build (publish)
├── CalendarCore/ # 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
│ └── build.gradle.kts # Public library build (SKIE, Metro, XCFramework)
├── CalendarKmpDav/ # 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 (UniFFI/Cargo, Metro)
├── CalendarResources/ # Shared Compose Multiplatform recurrence strings and translations
│ └── src/commonMain/composeResources/ # values/ and values-<lang>/strings.xml
├── Contacts/ # Standalone KMP contacts module (server address book + device contacts)
│ ├── src/commonMain/ # ContactsManager, merge/search/sync logic, Room tables and database, Ktor remote source
│ ├── src/androidMain/ # AndroidDeviceContactsProvider (ContactsContract)
│ ├── src/appleMain/ # AppleDeviceContactsProvider (Contacts framework), standalone database opener
│ ├── src/appleTargetMain/ # Added to each Apple target, reaches the Room-generated contacts DAO
│ └── build.gradle.kts # Contacts module build (Room, Ktor, publish)
├── MeetingRooms/ # Standalone KMP module for the Infomaniak bookable meeting rooms
│ ├── src/commonMain/ # MeetingRoomsManager, sync, availability, busy periods, Room tables, Ktor remote source
│ └── build.gradle.kts # MeetingRooms module build (Room, Ktor, publish)
├── Network/ # Shared Infomaniak API client (HttpClient factory, ApiResponse)
│ └── build.gradle.kts # Network module build (publish)
├── build.gradle.kts # Root aggregator (no sources)
├── buildRelease # Script to build & zip MultiplatformCalendar.xcframework for iOS/macOS release
└── buildRust # Script for standalone Rust compilation (optional, Gradle handles it)
| Module | Purpose |
|---|---|
| Account | Shared identity types (AccountId, AccessToken) used by CalendarCore, Contacts and MeetingRooms |
| CalendarCore | Public API: domain models, Room database, DAOs, repositories, managers, Apple CalendarSDK |
| CalendarKmpDav | Internal bridge: Rust/UniFFI CalDAV bridge, remote CalDAV models/client, CaldavClientModule |
| CalendarResources | Compose Multiplatform strings and plurals used by CalendarCore recurrence localization |
| Contacts | Standalone contacts module: server address book merged with device contacts, ETag sync |
| MeetingRooms | Meeting rooms the account can book (X-INFOMANIAK-BOOKABLE uuid): details, availability, bookings |
| Network | Shared Infomaniak API client: createHttpClient, ApiResponse, ApiErrorException |
CalendarCore provides two suspend extensions for RRULE descriptions:
import com.infomaniak.multiplatform_calendar.core.domain.model.event.toLocalizedRecurrenceString
import com.infomaniak.multiplatform_calendar.core.domain.model.event.recurrenceRule.toLocalizedString
val description: String? = event.toLocalizedRecurrenceString()
val ruleDescription: String = rule.toLocalizedString(start = dtStart, timeZone = startTimeZone)Event.toLocalizedRecurrenceString() automatically supplies the DTSTART context required to
describe the event's RRULE. Master events use their own timing.bounds, while materialized
occurrences use recurrenceMasterBounds so implicit RRULE selectors are always resolved against
the original master's DTSTART rather than the occurrence's start.
Overrides are enriched in the domain model with the master's RRULE and original bounds, so the same API can describe the recurrence of both generated occurrences and overridden occurrences.
The function returns null when there is no RRULE, including RDATE-only events. It describes the
RRULE itself and does not include RDATE/EXDATE exceptions.
RecurrenceRule.toLocalizedString() requires DTSTART because omitted weekday, day, or month
selectors may inherit values from it. Its optional time zone converts a UTC UNTIL to the event's
start time zone for display; without one, UTC is used. DATE and floating UNTIL values keep their
own calendar date.
Put recurrence strings and translations in CalendarResources/src/commonMain/composeResources/values*/strings.xml.
CalendarCore depends on this module and aggregates its resources into its Apple framework.
This is distinct from the Android app's CalendarComponents:Resources module in the consuming repository.
The submodule's .github/workflows/translations-validation.yml checks the Compose resource translations.
The MultiplatformCalendar.xcframework is produced by the CalendarCore module. :CalendarKmpDav 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 :CalendarKmpDav DavAccount at the repository boundary). The only :CalendarKmpDav 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 :CalendarKmpDav data type is exposed.
:Account and :Contacts are api dependencies of CalendarCore and are exported: AccountId, AccessToken,
ContactsManager and its types (Contact, …) are part of the public Swift API. :Account and :Contacts use the
generic com.infomaniak.multiplatform_core.* package root, as they are meant to move to a shared KMP core.
:MeetingRooms is exported the same way (MeetingRoomsManager and its types) but, being specific to the
calendar, uses the com.infomaniak.multiplatform_calendar.meeting_rooms package.
:Network is an implementation dependency of CalendarCore, Contacts and MeetingRooms, and is not exported. Like :Account, it uses the
com.infomaniak.multiplatform_core.* package root and has no DI, so it can move to a shared KMP core.
CalendarCore's CalendarDatabase hosts the tables of every module. Contacts and MeetingRooms only declare their entities and
DAO, public but @HiddenFromObjC, behind an interface (ContactsDatabase, MeetingRoomsDatabase) that CalendarDatabase implements.
On Apple, the contacts live in their own database instead (StandaloneContactsDatabase, opened at ContactsSettings.databasePath).
Room generates ContactDao_Impl in Contacts only: CalendarDatabase gets it through hostedContactDao instead of
declaring contactDao() abstract, which would generate a duplicate class and break the Android app's dex merge.
Apple consumers import MultiplatformCalendar and access the SDK through:
import MultiplatformCalendar
let sdk = CalendarSDKProvider.shared.sdk
sdk.accountManager.initAccount(...)
sdk.calendarManager.observeCalendars(...)kotlinx-datetime is an api dependency exported into the XCFramework, because its types (LocalDate,
LocalDateTime, TimeZone, YearMonth, …) cross the public API — calendarManager.observeDaySlices(start:end:timeZone:) takes
instants and keys its result by LocalDate. Exporting it gives Swift those types under their plain names
(LocalDate instead of an opaque Kotlinx_datetimeLocalDate), lets SKIE generate its Swift refinements for them,
and above all ships the Foundation converters Apple callers need to build their own values.
Kotlin → Foundation, as instance methods:
let date: Date = instant.toNSDate()
let comps: DateComponents = localDate.toNSDateComponents() // also on LocalDateTime and YearMonth
let tz: Foundation.TimeZone = kotlinTimeZone.toNSTimeZone()Foundation → Kotlin, through ConvertersKt:
let kotlinTz: MultiplatformCalendar.TimeZone = ConvertersKt.toKotlinTimeZone(timeZone)Two rough edges are worth knowing about, both coming from upstream rather than from this build:
-
TimeZoneis ambiguous. The exportedkotlinx.datetime.TimeZonecollides withFoundation.TimeZone, so Swift asks for a qualified name on either side (Foundation.TimeZone,MultiplatformCalendar.TimeZone) wherever both are in scope. -
Date→Instanthas no usable converter.ConvertersKt.toKotlinInstant(_:youShallNotPass:)takes anOverloadMarker, a markerkotlinx-datetime0.8 carries whileInstantmoves fromkotlinx.datetimetokotlin.time, and that type exposes no initialiser to Objective-C. Go through the epoch instead:let instant = KotlinInstant.companion.fromEpochMilliseconds( epochMilliseconds: Int64(date.timeIntervalSince1970 * 1000))
- Android:
AppGraph(in the Android app) is the@DependencyGraph. Core contributes shared graph accessors (CalendarCoreGraph) plusAndroidDatabaseModuleandDatabaseModule. The:CalendarKmpDavmodule contributesCaldavClientModule. The:Contactsmodule is wired by Core (ContactsModule+ platformDeviceContactsProviderbindings); its tables live inCalendarDatabase.AccountManager.initAccount/removeAccountalso init / remove the account inContactsManager. Likewise,:MeetingRoomsis wired by Core (MeetingRoomsModule), its tables live inCalendarDatabase, andAccountManagerinitializes / removes the account inMeetingRoomsManagertoo. - Apple:
CalendarSDK(inCalendarCore/appleMain) is the public@DependencyGraph. It provides the Apple Room database, inheritsCalendarCoreGraphexplicitly to exportaccountManager/calendarManager/contactsManager/meetingRoomsManager, and inherits:CalendarKmpDav'sCaldavClientModuleexplicitly to obtain the CalDAV bridge binding. It is accessed viaCalendarSDKProvider.shared.sdk(calendar:crashReport:contacts:),contactsgiving the path of the dedicated contacts database.
MeetingRoomsManager reads the bookable resources API on calendar.infomaniak.com and keeps only the meeting_room
ones. sync replaces the local rooms of each account (the API sends no ETag); observeRoom(accountId, uuid) resolves the
X-INFOMANIAK-BOOKABLE uuid of an event and fetches a room missing locally, staying null when that fails.
availability and isAvailable always hit the network, and busyPeriods does unless it is given no uuid. Booking itself is not an API call: it is the
X-INFOMANIAK-BOOKABLE property of the CalDAV event.
:Contacts reads the device contacts but never requests the permission itself: until access is granted, only the
server address book is searched.
- Android:
READ_CONTACTSis declared by the module and merged into the app manifest; the app requests it at runtime. - Apple: the app must add
NSContactsUsageDescriptionto itsInfo.plistand request access (CNContactStore.requestAccess(for: .contacts)).
Request it where the suggestions are first needed, e.g. when the user starts typing an attendee. Nothing else is needed once it is granted: the next search reads the device contacts.
Before you begin, ensure you have met the following requirements:
- You are using a Linux, macOS, or Windows machine.
- You have installed Java Development Kit (JDK) 21 or later.
- You have Android Studio installed.
- NDK 30.0.14904198 or newer: required for the 16 KB page size alignment Android 15+ mandates. Nothing to do manually:
the
ensure-ndk-versionconvention plugin reuses any newer installed NDK and otherwise downloads this one throughsdkmanager. See NDK handling. - You have Rust installed with cross-compilation targets:
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android \ aarch64-apple-ios aarch64-apple-ios-sim aarch64-apple-darwin - You have an active internet connection to download project dependencies.
The CalendarKmpDav/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 (see also
Extra CalDAV properties below).
The Rust → Kotlin/Swift bridge is handled automatically by uniffi-kotlin-multiplatform-bindings
- 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 single ch.ubique.uniffi.plugin
plugin. No separate build step is required — just run ./gradlew assembleAndroidMain.
ℹ️ The Android ABIs shipped are
arm64-v8a,armeabi-v7aandx86_64. The 32-bitx86ABI is not supported by the plugin; it only concerns long-obsolete 32-bit emulators.
Android 15+ requires shared libraries to be aligned on 16 KB pages, which needs NDK r28 (30.x) or newer.
com.android.kotlin.multiplatform.library exposes no ndkVersion of its own — AGP 9 removed it from CommonExtension —
so the NDK used to cross-compile Rust is the one given to cargo { ndkVersion = ... }. That plugin does not fail when
the requested version is missing: it silently falls back to the newest installed NDK, so a machine without a 30.x NDK
would build unaligned libraries and still report a successful build.
The ensure-ndk-version convention plugin (build-logic/plugins/.../ndk) closes that hole:
ensureNdkVersion {
minimumVersion = "30.0.14904198"
}
cargo {
ndkVersion = ensureNdkVersion.resolvedVersion
}minimumVersion is a minimum, not a pin: a newer installed NDK satisfies it and is reused, and only when none
qualifies is the declared version downloaded via sdkmanager. Wiring resolvedVersion into cargo is what makes the
check effective — repeating the version as a literal there would reintroduce the silent fallback.
Run ./gradlew :CalendarKmpDav:ensureNdkVersion to check or provision the NDK on its own. Automatic installation needs
the Android SDK Command-line Tools (Settings > Android SDK > SDK Tools); without them the build explains how to
install the NDK manually.
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.
- Reads (
discover,getEvents,getEventsInRange,syncCollection,getEventsByUrls) are fully cancellable and safe to cancel at any time. - Writes (
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. - Pure-CPU exports (
patchEventIcs,buildEventIcs) stay synchronous and are wrapped inwithContext(Dispatchers.Default)insideRustCaldavBridgeso they never block the caller's thread.
list_calendars does not request the current-user-privilege-set (RFC 3744), mapped to a CalendarAccessLevel: the
crate issues its own Depth: 1 PROPFIND on the home-set and reads it with fast-dav's multistatus parser (see
rust/caldav_bridge/src/props.rs). This request is best-effort: when it fails, the calendars default to read-write.
Discovery also reads the principal's calendar-user-address-set (RFC 6638) through fast-dav's
discover_schedule_endpoints (rust/caldav_bridge/src/principal.rs). Only its mailto: entries are kept, without their
scheme: they are the user's emails, matched against the attendees of an event. This request is best-effort: when it fails,
discovery goes on without the emails.
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 | ~3.4–4.9 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
CalendarKmpDav/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:
The Cargo profile is chosen by a single Gradle property, releaseBuild, which applies to every target (Android and
Apple alike). It defaults to true in gradle.properties, because a debug build weighs ~77 MB per Android ABI and
~140 MB for the Apple static lib, against ~4 MB / ~14 MB in release. When iterating on the crate itself, pass
-PreleaseBuild=false for much faster Rust rebuilds and native debug symbols.
# Fast local Rust rebuilds
./gradlew :CalendarCore:assembleMultiplatformCalendarDebugXCFramework -PreleaseBuild=falsestrip = true in the Cargo release profile only pays off on Android, where the shipped artifact is the cdylib
itself: it drops the .so symbol table while keeping the dynamic export table, so UniFFI's symbol lookups still resolve
(AAR: 8.0 MB → 6.5 MB). On Apple it changes nothing, because the shipped artifact is not the crate's own output —
the .a must keep its symbols to be linked into the framework. This is safe for binding generation too: the Ubique
plugin builds a separate host library (buildLibraryForBindings) that uniffi-bindgen reads, so the per-target
artifacts never need to be introspected.
The Apple framework is dynamic (isStatic not set), and the Rust .a is linked into it. Once that link is done
the ~34k local symbols coming from the static archive are dead weight — they inflate __LINKEDIT to ~5 MB. buildRelease
therefore runs strip -x on each XCFramework slice before zipping, which drops the local symbols while preserving the
global ones needed for linking and for dyld at runtime:
| Per slice (iosArm64) | Before | After |
|---|---|---|
| Binary | 19.3 MB | 15.0 MB |
__LINKEDIT |
5.0 MB | 737 KB |
| Symbols | 58,713 | 6,115 |
ℹ️
strip -S(drop DWARF) is deliberately not used: Kotlin/Native already sequesters the debug info into the companion.dSYM, so it is a no-op here. The.dSYMbundles are kept in the zip and their UUIDs still match the stripped binaries, so crashes remain symbolicable.
buildRelease archives with zip -r --symlinks. The flag is required: a macOS framework bundle exposes its binary
as Versions/A/<name> plus two symlinks (Versions/Current and the top-level one), and without --symlinks zip
dereferences them and stores the 14 MB binary three times (zip: 45 MB → 32 MB).
⚠️ The zip published by CI is not produced bybuildReleasebut by the reusable workflowInfomaniak/.github/.github/workflows/kmp-build-xcframework.yml, so any packaging change must be mirrored there.
# Build the MultiplatformCalendar XCFramework (iOS/macOS) — release Rust (see "Rust build profiles")
./gradlew :CalendarCore:assembleMultiplatformCalendarReleaseXCFramework -PreleaseBuild=true
# Build & zip for iOS release (updates `Package.swift` checksums when the file exists)
./buildRelease <version>
# Build the Android library (single variant AAR)
./gradlew assembleAndroidMain
# Run unit tests
./gradlew :CalendarCore:allTests
# Targeted recurrence tests: Android host (Robolectric), macOS (including real Compose resources), iOS Simulator
./gradlew :CalendarCore:testAndroidHostTest :CalendarCore:macosArm64Test :CalendarCore:iosSimulatorArm64Test
# 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.