feat: Add programmatic calendar CRUD operations and permission handling#1
feat: Add programmatic calendar CRUD operations and permission handling#1shdmfire wants to merge 7 commits into
Conversation
- Deprecate `createEvent` in favor of `openAddEvent` for system UI interaction. - Implement `insertEvent`, `queryEvents`, `updateEvent`, and `deleteEvent` for both Android and iOS. - Add `CalendarEventDraft`, `SystemCalendarEvent`, and `CalendarQuery` data models with built-in validation. - Introduce a `SystemCalendarException` hierarchy for granular error handling. - Update platform requirements, including new permission declarations in `AndroidManifest.xml` and `Info.plist`. - Expand the sample app and documentation to demonstrate programmatic event management. - Update dependencies and bump Android compile SDK to 37.
…nd disable iosX64 target
|
Thanks for the PR, I'll review your changes and come back with some feedbacks ! :) |
LotuxPunk
left a comment
There was a problem hiding this comment.
iOS side comments. More in follow up reviews per file.
| @Throws(SystemCalendarException::class, CancellationException::class) | ||
| actual suspend fun updateEvent(event: SystemCalendarEvent) { | ||
| event.validate() | ||
| ensureReadPermission() |
There was a problem hiding this comment.
Should be ensureWritePermission(), this is a write operation. Same bug below in deleteEvent at line 157.
Side effect of the current call: when permission is denied, the thrown CalendarPermissionException reports requiredAccess = ReadWrite instead of WriteOnly, which misleads callers about which scope to ask the user for.
| ensureReadPermission() | |
| ensureWritePermission() |
| @OptIn(ExperimentalForeignApi::class) | ||
| @Throws(SystemCalendarException::class, CancellationException::class) | ||
| actual suspend fun deleteEvent(eventId: String) { | ||
| ensureReadPermission() |
There was a problem hiding this comment.
Same issue as updateEvent, this is a write operation.
| ensureReadPermission() | |
| ensureWritePermission() |
| EKAuthorizationStatusAuthorized -> CalendarPermissionStatus.Granted | ||
| EKAuthorizationStatusDenied -> CalendarPermissionStatus.Denied | ||
| EKAuthorizationStatusRestricted -> CalendarPermissionStatus.Restricted | ||
| EKAuthorizationStatusNotDetermined -> CalendarPermissionStatus.NotDetermined | ||
| else -> CalendarPermissionStatus.Unknown |
There was a problem hiding this comment.
EKAuthorizationStatusWriteOnly (iOS 17+) and EKAuthorizationStatusFullAccess (iOS 17+) fall through to Unknown. So when a user grants write only access through the iOS 17 split prompt, the library returns Unknown and ensureWritePermission then rejects it, even though the user already said yes.
The CalendarPermissionStatus.WriteOnly enum value we added is currently unreachable on iOS.
| private suspend fun requestAccess(): CalendarPermissionStatus = suspendCancellableCoroutine { cont -> | ||
| eventStore.requestAccessToEntityType(EKEntityType.EKEntityTypeEvent) { granted, _ -> | ||
| cont.resume(if (granted) CalendarPermissionStatus.Granted else CalendarPermissionStatus.Denied) | ||
| } | ||
| } |
There was a problem hiding this comment.
requestAccessToEntityType is deprecated on iOS 17+. Both requestWritePermission and requestReadWritePermission funnel through here, which collapses the distinction we advertise in the public API.
We should call requestWriteOnlyAccessToEventsWithCompletion: for write only and requestFullAccessToEventsWithCompletion: for full access, parameterized by CalendarAccess. The matching Info.plist keys (NSCalendarsWriteOnlyAccessUsageDescription and NSCalendarsFullAccessUsageDescription) are already declared, so the system has everything it needs to show the right prompt.
| val urlString = "calshow:${eventId}" | ||
| val url = NSURL(string = urlString) | ||
| return platform.UIKit.UIApplication.sharedApplication.openURL(url) | ||
| } |
There was a problem hiding this comment.
Two problems here:
calshow:expects a numeric reference date offset (seconds since 2001 01 01), not anEKEvent.eventIdentifier. Calendar.app accepts the URL andopenURLreturnstrue, but it does not navigate to our event, it just opens to an arbitrary date. So the boolean return is misleading and the central feature silently fails.openURL(_:)(without options/completionHandler) is deprecated since iOS 10 and must run on the main thread. The suspend function makes noMaindispatcher guarantee.
Cleaner approach: resolve the event with eventStore.eventWithIdentifier(eventId) (throw CalendarEventNotFoundException if nil, which would actually honor the README contract), then present EKEventViewController on the stored presentingViewController.
| actual class CalendarEventManager { | ||
| private val eventStore = EKEventStore() | ||
| private lateinit var presentingViewController: UIViewController | ||
| private var editDelegate: EKEventEditViewDelegateProtocol? = null |
There was a problem hiding this comment.
EKEventEditViewController.editViewDelegate is a weak reference, this field exists only to keep the delegate alive across the suspend boundary. But it is never cleared, and a rapid second openAddEvent overwrites it while the first VC may still be on screen. Best case: stale delegate. Worst case: message to deallocated on a back tap.
Cleaner shape: rebuild the whole flow around suspendCancellableCoroutine. The delegate stays alive through the continuation's closure capture, no shared mutable state on the manager, and we can resume with a typed result (Saved, Cancelled, Deleted) instead of a misleading Boolean.
| actual suspend fun requestWritePermission(): CalendarPermissionStatus { | ||
| return currentPermission() | ||
| } | ||
|
|
||
| actual suspend fun requestReadWritePermission(): CalendarPermissionStatus { | ||
| return currentPermission() | ||
| } |
There was a problem hiding this comment.
These two methods don't actually request anything, they just return currentPermission(). The OS permission dialog is never shown. A first launch app will see Denied immediately with no UI displayed to the user.
Two options:
- Take an
Activity(orActivityResultRegistryOwner) at setup time and useActivityResultContracts.RequestMultiplePermissionswrapped insuspendCancellableCoroutine. - Or delete these methods and only expose
currentPermission(), documenting that the host app is responsible for triggering the permission flow.
Having a method named requestX that does not request anything is a silent API lie.
| event.validate() | ||
| ensureWritePermission() | ||
|
|
||
| val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, event.id.toLong()) |
There was a problem hiding this comment.
.toLong() here throws NumberFormatException outside the try/catch, so the exception is not a SystemCalendarException and escapes the documented @Throws contract on this method.
Any non numeric ID (an iOS UUID style identifier from cross platform code paths, a corrupted persisted value, a hand typed test ID) crashes the coroutine, bypassing the typed catch that callers wrote against our hierarchy.
We should wrap in a typed helper that throws CalendarValidationException or CalendarEventNotFoundException:
private fun parseEventId(id: String): Long =
id.toLongOrNull() ?: throw CalendarEventNotFoundException(id)Same issue at line 204 (deleteEvent), line 220 (openEvent), and line 95 (reminder insertion).
| try { | ||
| val uri = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values) | ||
| ?: throw CalendarOperationFailedException( | ||
| operation = CalendarOperation.Insert, | ||
| message = "ContentResolver.insert returned null" | ||
| ) | ||
|
|
||
| val eventId = ContentUris.parseId(uri).toString() | ||
|
|
||
| event.alarmMinutesBefore.forEach { minutes -> | ||
| val reminderValues = ContentValues().apply { | ||
| put(CalendarContract.Reminders.EVENT_ID, eventId.toLong()) | ||
| put(CalendarContract.Reminders.MINUTES, minutes) | ||
| put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) | ||
| } | ||
| context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, reminderValues) | ||
| } | ||
|
|
||
| return eventId | ||
| } catch (e: SecurityException) { | ||
| throw CalendarPermissionException(CalendarAccess.WriteOnly, cause = e) | ||
| } catch (e: Exception) { | ||
| throw CalendarOperationFailedException(CalendarOperation.Insert, e.message ?: "Insert failed", cause = e) | ||
| } |
There was a problem hiding this comment.
This try + catch (SecurityException) -> CalendarPermissionException + catch (Exception) -> CalendarOperationFailedException shape is duplicated four times: insertEvent (here), queryEvents, updateEvent, deleteEvent. The drift is already visible: queryEvents reports CalendarAccess.ReadWrite while the others report WriteOnly.
Extract a small inline helper:
private inline fun <T> runCalendar(
op: CalendarOperation,
access: CalendarAccess,
block: () -> T
): T = try { block() }
catch (e: SecurityException) { throw CalendarPermissionException(access, cause = e) }
catch (e: Exception) { throw CalendarOperationFailedException(op, e.message ?: "$op failed", cause = e) }| private fun ensureWritePermission() { | ||
| if (!isPermissionDeclared(Manifest.permission.WRITE_CALENDAR)) { | ||
| throw CalendarPermissionNotDeclaredException( | ||
| platform = CalendarPlatform.Android, | ||
| message = "android.permission.WRITE_CALENDAR is not declared in AndroidManifest.xml" | ||
| ) | ||
| } | ||
| if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) != PackageManager.PERMISSION_GRANTED) { | ||
| throw CalendarPermissionException(CalendarAccess.WriteOnly) | ||
| } | ||
| } | ||
|
|
||
| private fun ensureReadPermission() { | ||
| if (!isPermissionDeclared(Manifest.permission.READ_CALENDAR)) { | ||
| throw CalendarPermissionNotDeclaredException( | ||
| platform = CalendarPlatform.Android, | ||
| message = "android.permission.READ_CALENDAR is not declared in AndroidManifest.xml" | ||
| ) | ||
| } | ||
| if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED) { | ||
| throw CalendarPermissionException(CalendarAccess.ReadWrite) | ||
| } | ||
| } |
There was a problem hiding this comment.
ensureWritePermission and ensureReadPermission are 95% identical, the only difference is the Manifest constant and the CalendarAccess level. Collapse to a single parameterized helper:
private fun ensurePermission(access: CalendarAccess) {
val permission = when (access) {
CalendarAccess.WriteOnly -> Manifest.permission.WRITE_CALENDAR
CalendarAccess.ReadWrite -> Manifest.permission.READ_CALENDAR
}
if (!isPermissionDeclared(permission)) {
throw CalendarPermissionNotDeclaredException(
platform = CalendarPlatform.Android,
message = "$permission is not declared in AndroidManifest.xml"
)
}
if (ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) {
throw CalendarPermissionException(access)
}
}This would also make the iOS bug above (updateEvent/deleteEvent passing the wrong access level) much harder to introduce, since the parameter name forces a deliberate choice.
|
|
||
| @OptIn(ExperimentalTime::class) | ||
| @Throws(SystemCalendarException::class, CancellationException::class) | ||
| actual suspend fun insertEvent(event: CalendarEventDraft): String { |
There was a problem hiding this comment.
None of the suspend entry points hop dispatchers. Every CRUD method performs synchronous binder IPC into the Calendar provider on whichever dispatcher the caller used, typically Dispatchers.Main from Compose. A populated provider with active sync can take 100+ ms, freezing the UI and risking ANRs.
Wrap each platform body once:
actual suspend fun insertEvent(event: CalendarEventDraft): String = withContext(Dispatchers.IO) {
// ...
}Apply the same wrapping to queryEvents, updateEvent, deleteEvent, and currentPermission. iOS has the same issue, eventsMatchingPredicate and saveEvent are synchronous EventKit calls that should not run on Main.
| event.alarmMinutesBefore.forEach { minutes -> | ||
| val reminderValues = ContentValues().apply { | ||
| put(CalendarContract.Reminders.EVENT_ID, eventId.toLong()) | ||
| put(CalendarContract.Reminders.MINUTES, minutes) | ||
| put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) | ||
| } | ||
| context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, reminderValues) | ||
| } |
There was a problem hiding this comment.
Each alarm is inserted with a separate contentResolver.insert() call, producing N+1 round trips to the provider and a non atomic outcome. If the process dies mid loop, the event is stored with a partial reminder set.
Use ContentProviderOperation.applyBatch() with withValueBackReference(EVENT_ID, 0) so the event and its reminders commit atomically in a single IPC. Failing that, at least use bulkInsert on the Reminders URI to batch them into one call.
|
|
||
| while (cursor.moveToNext()) { | ||
| val calendarId = cursor.getString(calIdIdx) | ||
| if (query.calendarIds.isNotEmpty() && calendarId !in query.calendarIds) continue |
There was a problem hiding this comment.
The provider has already materialized every row by the time we filter, and we then throw most of them away in Kotlin. Push the filter into SQL:
val selection = if (query.calendarIds.isNotEmpty())
"${CalendarContract.Instances.CALENDAR_ID} IN (${query.calendarIds.joinToString(",") { "?" }})"
else null
val selectionArgs = query.calendarIds.takeIf { it.isNotEmpty() }?.toTypedArray()Then drop the continue check inside the loop. SQLite filters at the DB layer, and only matching rows cross the binder boundary.
LotuxPunk
left a comment
There was a problem hiding this comment.
Common module comments, focused on elegance.
| val location: String? = null, | ||
| val url: String? = null, | ||
| ) | ||
| ) { |
There was a problem hiding this comment.
Event keeps LocalDateTime while the new CalendarEventDraft and SystemCalendarEvent use Instant. Two data classes that differ only in time representation.
The Android impl is forced to convert via .toInstant(TimeZone.currentSystemDefault()), which silently anchors a wall clock time to the device's current timezone. Surprising for callers who already have an Instant, broken across DST gaps, and divergent from the draft path that takes Instant directly.
I would either deprecate Event and unify on CalendarEventDraft (allow id and calendarId to be nullable for the system UI path), or migrate Event.startDate/endDate to Instant. One canonical model.
| fun validate() { | ||
| if (title.isBlank()) { | ||
| throw CalendarValidationException("Event title cannot be blank") | ||
| } | ||
|
|
||
| if (endDate <= startDate) { | ||
| throw CalendarValidationException("Event end date must be after start date") | ||
| } | ||
|
|
||
| validateOptionalUrl(url) | ||
| } |
There was a problem hiding this comment.
My initial feeling is these four validate() methods should be init { } blocks. The data classes have no other invariants to defend, and the cost of remembering to call .validate() at every entry point is exactly the cost the validation is trying to prevent.
On iOS, deleteEvent currently doesn't call event.validate() (it takes a raw String), which is a small example of the bug pattern this enables.
Moving each validate() body into init { ... }:
- Makes invalid states unrepresentable (parse don't validate).
- Removes the public
fun validate()surface. - Removes around 8
.validate()calls in the platform impls. initalso runs on.copy(...), so that is free.
Same applies to the three sibling data classes below (CalendarEventDraft, SystemCalendarEvent, CalendarQuery).
| enum class CalendarPermissionStatus { | ||
| NotDetermined, | ||
| Granted, | ||
| Denied, | ||
| Restricted, | ||
| WriteOnly, | ||
| Unknown | ||
| } |
There was a problem hiding this comment.
Granted and WriteOnly aren't orthogonal. What does Granted mean if WriteOnly is also reachable? Currently WriteOnly is unreachable on iOS (see the currentPermission comment), and on Android WriteOnly means "write granted, read denied", a weird state to even surface.
Cleaner shape, makes the access level explicit:
sealed interface CalendarPermissionStatus {
object NotDetermined : CalendarPermissionStatus
object Denied : CalendarPermissionStatus
object Restricted : CalendarPermissionStatus
object Unknown : CalendarPermissionStatus
data class Granted(val access: CalendarAccess) : CalendarPermissionStatus
}Now Granted(WriteOnly) vs Granted(ReadWrite) is explicit and exhaustive when works for callers.
| sealed class SystemCalendarException( | ||
| message: String, | ||
| cause: Throwable? = null | ||
| ) : Exception(message, cause) |
There was a problem hiding this comment.
The exception could probably be a sealed interface with the different options, rather than six concrete sealed class subclasses with overlapping concerns.
Today CalendarPermissionException and CalendarPermissionNotDeclaredException both encode "permission problem". CalendarUnavailableException and CalendarOperationFailedException overlap. A caller writing a generic "permission failed, show settings" handler has to catch two types, and a new permission state in iOS 18 would force a new subclass plus break every exhaustive when in consumer code.
sealed interface SystemCalendarException {
data class Permission(val reason: Reason, val access: CalendarAccess) : SystemCalendarException, RuntimeException()
data class Validation(override val message: String) : SystemCalendarException, IllegalArgumentException(message)
data class NotFound(val eventId: String) : SystemCalendarException, RuntimeException()
data class Unavailable(val message: String) : SystemCalendarException, RuntimeException()
data class OperationFailed(val op: CalendarOperation, override val cause: Throwable?) : SystemCalendarException, RuntimeException()
enum class Reason { NotGranted, NotDeclared }
}Caveat: @Throws(SystemCalendarException::class) requires a concrete Throwable for Kotlin/Native to ObjC error mapping, so each variant has to extend RuntimeException (or another throwable). Slightly more verbose to declare, much cleaner for callers, and exhaustive when works.
Update iOS EventKit permission handling so write operations require write access, iOS 17 full-access and write-only authorization states are recognized, and permission requests use the dedicated write-only/full-access APIs instead of the deprecated generic request API. Replace the iOS calshow URL path with EKEvent lookup and EKEventViewController presentation, keeping UIKit presentation on the main dispatcher while moving synchronous EventKit work to IO. Move Android calendar provider operations to Dispatchers.IO, safely parse event ids before building provider URIs or reminder rows, and deprecate Android request permission helpers because runtime permission UI must be owned by the host app. Document Android host-app permission requests and upgrade Kotlin to 2.3.21 so the Compose compiler plugin remains aligned with the Kotlin plugin version.
Introduce a separate :androidApp module for the Android application entry point and move MainActivity, the Android manifest, strings, and launcher resources out of :composeApp. Convert :composeApp from an Android application into a shared KMP UI module backed by the AGP Android KMP library plugin, keeping app-only Android dependencies and packaging in :androidApp. Migrate :library from com.android.library to com.android.kotlin.multiplatform.library and configure the Android target through the KMP android block. Enable AGP 9 built-in Kotlin defaults by removing deprecated android.builtInKotlin and android.newDsl overrides. Fix the iOS CalendarEventManager updateEvent actual implementation so it matches the common Unit return type after saving the EventKit event. Verified with :library:compileAndroidMain, :composeApp:compileAndroidMain, :androidApp:assembleDebug, :library:compileKotlinIosSimulatorArm64, :library:compileKotlinIosArm64, and :composeApp:compileKotlinIosSimulatorArm64.
Deduplicate platform permission checks with access-aware helpers and route calendar provider/EventKit failures through centralized operation wrappers. Use Android provider batch operations for event and reminder insertion, including reminder back references, so reminders are inserted atomically with the event. Push Android calendar id filtering into the provider selection instead of filtering queried rows in Kotlin.
Run validation from model construction for Event, CalendarEventDraft, SystemCalendarEvent, and CalendarQuery while keeping validate() as a deprecated compatibility entry point. Replace the permission status enum with a sealed model that carries the granted access level, and update Android and iOS mappings for read/write and write-only states. Add a shared permission problem contract and reason enum so not-granted and not-declared permission failures stay distinguishable while preserving the existing exception class names.
Declare Compose Multiplatform artifacts in the version catalog and update modules to use libs.compose.* dependencies. Switch sample previews to the AndroidX Preview annotation backed by ui-tooling-preview.
|
Thanks for the detailed review, and sorry for the delayed update — I’ve been quite busy recently and only had time to address this about five days later. I’ve pushed an update to my fork addressing the feedback. The main changes are:
I also migrated the Android sample into a dedicated I verified the changes with: Please let me know if there is anything else you’d like me to adjust. |
createEventin favor ofopenAddEventfor system UI interaction.insertEvent,queryEvents,updateEvent, anddeleteEventfor both Android and iOS.CalendarEventDraft,SystemCalendarEvent, andCalendarQuerydata models with built-in validation.SystemCalendarExceptionhierarchy for granular error handling.AndroidManifest.xmlandInfo.plist.Note: The Android implementation has been tested locally. However, I do not have access to an iOS device, so I could not fully verify the iOS functionality on real hardware. I would appreciate it if the maintainers could help test and review the iOS implementation. Thank you!