fix(calendar): don't register calendar observer before permission granted #19

Merged
makiolaj merged 1 commits from fix/calendar-observer-permission-crash into main 2026-06-21 09:59:33 +00:00
3 changed files with 72 additions and 16 deletions

View File

@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [2.7.0] — 2026-06-18
### Added ### Added

View File

@@ -28,8 +28,8 @@ android {
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH // the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local // (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. // default; keep them matching the latest released tag. See docs/RELEASING.md.
versionCode = 20700 versionCode = 20701
versionName = "2.7.0" versionName = "2.7.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -1,9 +1,11 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.Manifest
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.database.ContentObserver import android.database.ContentObserver
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
@@ -11,6 +13,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.CalendarContract import android.provider.CalendarContract
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
@@ -162,14 +165,52 @@ class AndroidCalendarDataSource @Inject constructor(
) : CalendarDataSource { ) : CalendarDataSource {
private val resolver: ContentResolver get() = context.contentResolver private val resolver: ContentResolver get() = context.contentResolver
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
override fun calendars(): List<CalendarSource> = resolver.query( // All access to these two collections is guarded by [observerLock] because
CalendarContract.Calendars.CONTENT_URI, // listeners are registered on the main thread (repository init, via ViewModel
CalendarProjection.COLUMNS, // creation) while [ensureObserversRegistered] runs on the IO dispatcher.
null, null, private val observerLock = Any()
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC", private val observers = mutableMapOf<() -> Unit, ContentObserver>()
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList() private val registeredObservers = mutableSetOf<ContentObserver>()
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<CalendarSource> {
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 * 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<EventInstance> { override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
ensureObserversRegistered()
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis) ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis) ContentUris.appendId(this, endMillis)
@@ -718,16 +760,20 @@ class AndroidCalendarDataSource @Inject constructor(
listener() listener()
} }
} }
observers[listener] = obs synchronized(observerLock) {
resolver.registerContentObserver( observers[listener] = obs
CalendarContract.CONTENT_URI, // Attach now if we already hold the permission; otherwise it stays
/* notifyForDescendants = */ true, // pending and re-attaches on the first read after the grant.
obs, registerObserverLocked(obs)
) }
} }
override fun unregisterChangeListener(listener: () -> Unit) { 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<Attendee> = resolver.query( private fun queryAttendees(eventId: Long): List<Attendee> = resolver.query(