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(