Compare commits

...

2 Commits

Author SHA1 Message Date
297a2350e3 Merge pull request 'fix(calendar): don't register calendar observer before permission granted' (#19) from fix/calendar-observer-permission-crash into main
All checks were successful
CI / ci (push) Successful in 4m8s
Release — F-Droid repo + Gitea release / ci (push) Successful in 2m29s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 5s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 5m43s
Reviewed-on: #19
2026-06-21 09:59:32 +00:00
997ee44792 fix(calendar): don't register calendar observer before permission granted
All checks were successful
CI / ci (push) Successful in 4m15s
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) <noreply@anthropic.com>
2026-06-21 11:52:28 +02: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]
## [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

View File

@@ -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"
}

View File

@@ -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<CalendarSource> = 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<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
@@ -242,6 +283,7 @@ class AndroidCalendarDataSource @Inject constructor(
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
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<Attendee> = resolver.query(