From 997ee44792eb0a186e98259f8ce89ff49068e08f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 21 Jun 2026 11:52:28 +0200 Subject: [PATCH] fix(calendar): don't register calendar observer before permission granted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository registers a ContentObserver on the calendar provider eagerly in its init block, and an activity-scoped SettingsViewModel (which drives the theme) injects that repository — so the @Singleton is constructed at launch, above RootScreen's permission gate. On newer Android, registering an observer on a provider you lack permission for throws SecurityException instead of silently no-op'ing, so the app crashed instantly on every launch whenever calendar access wasn't granted (fresh install or revoked permission), before the permission screen could ever appear. Guard the registration behind a calendar-permission check and re-attach the observer lazily on the first calendars()/instances() read, which runs once the gate opens and screens subscribe. Access to the observer collections is now synchronized since registration can happen on the main thread (repo init) or the IO dispatcher (query re-attach). Verified on-device: permission-denied launch shows the permission screen instead of crashing; granting it proceeds to the calendar with live updates. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 +++ app/build.gradle.kts | 4 +- .../data/calendar/CalendarDataSource.kt | 74 +++++++++++++++---- 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdffc43..8e405bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.7.1] — 2026-06-21 + +### Fixed +- Fixed the app crashing immediately on launch whenever calendar access hadn't + been granted yet (a fresh install, or after revoking the permission). The app + set up its live calendar-change listener before the permission screen could + appear, which newer Android versions reject outright — so the app died before + you could grant access. The listener now waits for the permission and attaches + itself the moment it's granted. + ## [2.7.0] — 2026-06-18 ### Added diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8c14444..7d4e97a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH // (e.g. v2.0.0 -> 20000). These committed values are the dev/local // default; keep them matching the latest released tag. See docs/RELEASING.md. - versionCode = 20700 - versionName = "2.7.0" + versionCode = 20701 + versionName = "2.7.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 7aa838e..11edaf6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -1,9 +1,11 @@ package de.jeanlucmakiola.calendula.data.calendar +import android.Manifest import android.content.ContentResolver import android.content.ContentUris import android.content.ContentValues import android.content.Context +import android.content.pm.PackageManager import android.database.ContentObserver import android.database.Cursor import android.net.Uri @@ -11,6 +13,7 @@ import android.os.Handler import android.os.Looper import android.provider.CalendarContract import android.util.Log +import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.domain.Attendee import de.jeanlucmakiola.calendula.domain.CalendarSource @@ -162,14 +165,52 @@ class AndroidCalendarDataSource @Inject constructor( ) : CalendarDataSource { private val resolver: ContentResolver get() = context.contentResolver - private val observers = mutableMapOf<() -> Unit, ContentObserver>() - override fun calendars(): List = resolver.query( - CalendarContract.Calendars.CONTENT_URI, - CalendarProjection.COLUMNS, - null, null, - CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC", - )?.use { it.mapAll(::toCalendarSource) } ?: emptyList() + // All access to these two collections is guarded by [observerLock] because + // listeners are registered on the main thread (repository init, via ViewModel + // creation) while [ensureObserversRegistered] runs on the IO dispatcher. + private val observerLock = Any() + private val observers = mutableMapOf<() -> Unit, ContentObserver>() + private val registeredObservers = mutableSetOf() + + private fun hasCalendarPermission(): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) == + PackageManager.PERMISSION_GRANTED + + /** + * Attach any not-yet-registered observers to the provider, but only once the + * calendar permission is held. Registering a ContentObserver on the calendar + * provider without that permission throws SecurityException on newer Android + * (it used to silently no-op), which crashed the app at launch — the repo + * registers its observer eagerly, before the permission gate. Called from the + * observed reads ([calendars]/[instances]) so the observer re-attaches the + * first time a screen queries after the permission is granted. + */ + private fun ensureObserversRegistered() { + if (!hasCalendarPermission()) return + synchronized(observerLock) { + if (registeredObservers.size == observers.size) return + observers.values.forEach(::registerObserverLocked) + } + } + + private fun registerObserverLocked(obs: ContentObserver) { + if (obs in registeredObservers || !hasCalendarPermission()) return + runCatching { + resolver.registerContentObserver(CalendarContract.CONTENT_URI, true, obs) + }.onSuccess { registeredObservers += obs } + .onFailure { Log.w(TAG, "Calendar observer registration skipped", it) } + } + + override fun calendars(): List { + ensureObserversRegistered() + return resolver.query( + CalendarContract.Calendars.CONTENT_URI, + CalendarProjection.COLUMNS, + null, null, + CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC", + )?.use { it.mapAll(::toCalendarSource) } ?: emptyList() + } /** * Calendar-row writes must address the provider as a sync adapter and name @@ -242,6 +283,7 @@ class AndroidCalendarDataSource @Inject constructor( } override fun instances(beginMillis: Long, endMillis: Long): List { + ensureObserversRegistered() val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { ContentUris.appendId(this, beginMillis) ContentUris.appendId(this, endMillis) @@ -718,16 +760,20 @@ class AndroidCalendarDataSource @Inject constructor( listener() } } - observers[listener] = obs - resolver.registerContentObserver( - CalendarContract.CONTENT_URI, - /* notifyForDescendants = */ true, - obs, - ) + synchronized(observerLock) { + observers[listener] = obs + // Attach now if we already hold the permission; otherwise it stays + // pending and re-attaches on the first read after the grant. + registerObserverLocked(obs) + } } override fun unregisterChangeListener(listener: () -> Unit) { - observers.remove(listener)?.let { resolver.unregisterContentObserver(it) } + synchronized(observerLock) { + observers.remove(listener)?.let { obs -> + if (registeredObservers.remove(obs)) resolver.unregisterContentObserver(obs) + } + } } private fun queryAttendees(eventId: Long): List = resolver.query(