fix(calendars): hide-only visibility reconcile, and keep it working read-only

Code review of the one-visibility-model fix (#75) found the reconciliation
reaching further than it should and the read-only case falling through it.

The migration switched calendars *on* to keep the upgrade invisible, but
"not disabled in Calendula" is the default for every calendar, including ones
the user deliberately hid in Google Calendar, Etar or DAVx5 — those would
reappear there and start firing reminders from a switch the user never touched.
Its sync_events guard didn't hold either: an ACCOUNT_TYPE_LOCAL calendar another
app created can sit at sync_events=0 while holding real device-local events. The
reconcile now only hides, and a one-time notice explains that visibility follows
the device and where to change it, instead of quietly rewriting other apps'
state.

Only READ_CALENDAR gates the app, so a read-only install could not write the
flag at all: every calendar it had switched off came back with its events and
its reminders, and the switch couldn't undo it. Those switch-offs are kept
app-side now (the retired disabled-set key, re-read under a new name), folded
into the visibility every consumer reads, and drained into the provider entry by
entry once WRITE_CALENDAR arrives — which also makes a part-applied run resumable
without re-applying a switch the user has since flipped by hand.

Also: restore the ReminderNotifier.post gate, the one path a snooze re-shown
from our own alarm passes; move the whole reconcile inside its try/catch, so a
damaged preferences file can't crash the process at launch; share one Calendars
query per provider tick across the flows that need it; and give the reworded
Settings hint new keys, so five locales stop rendering the retired app-only
wording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 13:16:55 +02:00
parent 41593a0e9d
commit ef48717e2c
28 changed files with 687 additions and 265 deletions

View File

@@ -15,9 +15,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
alarms for calendars marked visible, and Calendula kept its own separate alarms for calendars marked visible, and Calendula kept its own separate
on/off list that had no say in it. There is now one switch: **Settings → on/off list that had no say in it. There is now one switch: **Settings →
Calendars** turns a calendar on or off for the whole device, so what you see Calendars** turns a calendar on or off for the whole device, so what you see
and what reminds you can no longer disagree. Your current selection is carried and what reminds you can no longer disagree ([#75]).
over on first launch, and calendars whose events aren't stored on this device
are left as they are ([#75]). Calendars you had switched off in Calendula are switched off here too on first
launch. Calendars that were already off — hidden in another calendar app, or
never switched on after being added — stay off, and Calendula says so once
rather than quietly switching them on for every app on your device; you can
turn any of them back on in Settings → Calendars.
If you gave Calendula read-only access to your calendars, the switch still
works: your choice is kept in the app until it can be written.
The drawer's filter is unchanged and still app-only: hiding a calendar there The drawer's filter is unchanged and still app-only: hiding a calendar there
tidies your view without silencing its reminders. tidies your view without silencing its reminders.

View File

@@ -4,7 +4,7 @@ import android.app.Application
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityMigration import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
import de.jeanlucmakiola.calendula.data.backup.BackupWorker import de.jeanlucmakiola.calendula.data.backup.BackupWorker
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
@@ -40,21 +40,23 @@ class CalendulaApp : Application() {
) )
reconcileAutoBackup() reconcileAutoBackup()
reconcileSpecialDates() reconcileSpecialDates()
migrateCalendarVisibility() reconcileCalendarVisibility()
} }
/** /**
* Fold the retired app-local "disabled calendars" set into the system's * Flush any calendar switch-off the app hasn't been allowed to write into
* `Calendars.VISIBLE` once (#75). A no-op after it has run, and on a fresh * the system's `Calendars.VISIBLE` yet — including the retired app-local
* install; a launch without the calendar permissions leaves it pending, and * "disabled calendars" set the upgrade inherits (#75). A no-op on a fresh
* granting them on the permission screen runs it there instead. * install and in the steady state; a launch without the calendar permissions
* leaves the set pending, and granting them on the permission screen runs it
* there instead.
*/ */
private fun migrateCalendarVisibility() { private fun reconcileCalendarVisibility() {
val deps = EntryPointAccessors.fromApplication( val deps = EntryPointAccessors.fromApplication(
this, CalendarVisibilityMigration.Deps::class.java, this, CalendarVisibilityReconciler.Deps::class.java,
) )
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
deps.calendarVisibilityMigration().runIfNeeded() deps.calendarVisibilityReconciler().run()
} }
} }

View File

@@ -120,6 +120,22 @@ interface CalendarDataSource {
*/ */
fun setCalendarVisible(id: Long, visible: Boolean) fun setCalendarVisible(id: Long, visible: Boolean)
/**
* Whether one calendar is currently switched on at system level, without
* reading every row — for the reminder gate, which sees a calendar id and
* nothing else. Null when the answer can't be had: no row (the calendar was
* deleted) or no read permission.
*/
fun isCalendarVisible(id: Long): Boolean?
/**
* Whether the app holds `WRITE_CALENDAR`, i.e. may write
* [setCalendarVisible] at all. Read-only users (READ granted, WRITE denied)
* keep their calendar switches app-side instead — see
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds].
*/
fun canWriteCalendars(): Boolean
/** /**
* Create a local calendar tagged as the special-dates mirror for [type] * Create a local calendar tagged as the special-dates mirror for [type]
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal * (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
@@ -404,6 +420,19 @@ class AndroidCalendarDataSource @Inject constructor(
if (rows == 0) throw WriteFailedException("set calendar visibility id=$id") if (rows == 0) throw WriteFailedException("set calendar visibility id=$id")
} }
override fun isCalendarVisible(id: Long): Boolean? {
if (!hasCalendarPermission()) return null
return resolver.query(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
arrayOf(CalendarContract.Calendars.VISIBLE),
null, null, null,
)?.use { if (it.moveToFirst()) it.getInt(0) != 0 else null }
}
override fun canWriteCalendars(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long { override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR } val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply { val values = ContentValues().apply {

View File

@@ -44,6 +44,11 @@ interface CalendarRepository {
* [CalendarDataSource.setCalendarVisible]. Each calendar is written on its * [CalendarDataSource.setCalendarVisible]. Each calendar is written on its
* own, in order; a failure part-way leaves the earlier writes standing (the * own, in order; a failure part-way leaves the earlier writes standing (the
* observer reports whatever actually landed). * observer reports whatever actually landed).
*
* Without `WRITE_CALENDAR` the choice is kept app-side instead (see
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds]),
* where it filters events and reminders just the same until it can be
* written.
*/ */
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)

View File

@@ -20,7 +20,10 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicLong
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -47,23 +50,43 @@ class CalendarRepositoryImpl @Inject constructor(
extraBufferCapacity = 1, extraBufferCapacity = 1,
) )
/**
* Bumped on every provider notification, so one tick's calendar read can be
* shared by everything that needs it (see [calendarsSnapshot]).
*/
private val generation = AtomicLong(0L)
init { init {
dataSource.registerChangeListener { ticks.tryEmit(Unit) } dataSource.registerChangeListener {
generation.incrementAndGet()
ticks.tryEmit(Unit)
}
} }
// A switch-off the app hasn't been allowed to write yet is folded into the
// flag itself, so every consumer — the Settings switch, the filter sheet,
// the form and import pickers, the widgets — reads one visibility and can't
// disagree with what the user just tapped. The reconciler reads the data
// source directly, because it needs the provider's own answer.
override fun calendars(): Flow<List<CalendarSource>> = override fun calendars(): Flow<List<CalendarSource>> =
ticks combine(
.onStart { emit(Unit) } ticks.onStart { emit(Unit) }.reQuery { calendarsSnapshot() },
.reQuery { dataSource.calendars() } prefs.pendingDisabledCalendarIds,
.flowOn(io) ) { calendars, pendingDisabled ->
if (pendingDisabled.isEmpty()) calendars
else calendars.map {
if (it.id in pendingDisabled) it.copy(isVisibleInSystem = false) else it
}
}.flowOn(io)
// Instances are filtered by the system's per-calendar VISIBLE flag the // Instances are filtered by the system's per-calendar VISIBLE flag the
// app-side hidden set: an event is dropped when the user switched its // switch-offs still waiting to be written to it the app-side hidden set:
// calendar off in Settings → Calendars (which also stops the provider // an event is dropped when the user switched its calendar off in Settings →
// scheduling its reminders) *or* hid it in the filter sheet. Re-runs when // Calendars (which also stops the provider scheduling its reminders) *or*
// the provider ticks — writing VISIBLE notifies, so switching a calendar // hid it in the filter sheet. Re-runs when the provider ticks — writing
// updates every view — or when the hidden set changes. [calendars] stays // VISIBLE notifies, so switching a calendar updates every view — or when
// unfiltered so those screens can list and re-enable invisible calendars. // either set changes. [calendars] stays unfiltered so those screens can list
// and re-enable invisible calendars.
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> = override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
combine( combine(
ticks ticks
@@ -80,8 +103,9 @@ class CalendarRepositoryImpl @Inject constructor(
) )
}, },
prefs.hiddenCalendarIds, prefs.hiddenCalendarIds,
) { queried, hidden -> prefs.pendingDisabledCalendarIds,
val excluded = hidden + queried.invisibleCalendarIds ) { queried, hidden, pendingDisabled ->
val excluded = hidden + pendingDisabled + queried.invisibleCalendarIds
if (excluded.isEmpty()) queried.instances if (excluded.isEmpty()) queried.instances
else queried.instances.filterNot { it.calendarId in excluded } else queried.instances.filterNot { it.calendarId in excluded }
} }
@@ -99,17 +123,42 @@ class CalendarRepositoryImpl @Inject constructor(
) )
/** Calendars switched off at system level — hidden, and never reminded about. */ /** Calendars switched off at system level — hidden, and never reminded about. */
private fun invisibleCalendarIds(): Set<Long> = dataSource.calendars() private suspend fun invisibleCalendarIds(): Set<Long> = calendarsSnapshot()
.filterNot { it.isVisibleInSystem } .filterNot { it.isVisibleInSystem }
.mapTo(mutableSetOf()) { it.id } .mapTo(mutableSetOf()) { it.id }
private val calendarsLock = Mutex()
private var cachedGeneration = -1L
private var cachedCalendars: List<CalendarSource> = emptyList()
/**
* The calendar list for the current tick, queried once and shared. Every
* open view collects [calendars] *and* filters its instances by visibility,
* which used to cost one full `Calendars` query each per tick. Reusing a
* single read also keeps them consistent: within a tick, what a screen lists
* and what its events are filtered against can't come from two snapshots.
*
* An empty result is never cached — it is what a read without the calendar
* permission returns, and the grant itself doesn't notify the provider.
*/
private suspend fun calendarsSnapshot(): List<CalendarSource> = calendarsLock.withLock {
val current = generation.get()
if (current != cachedGeneration || cachedCalendars.isEmpty()) {
cachedCalendars = dataSource.calendars()
cachedGeneration = current
}
cachedCalendars
}
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) { override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId) dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
} }
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) { override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
if (query.isBlank()) return@withContext emptyList() if (query.isBlank()) return@withContext emptyList()
val excluded = prefs.hiddenCalendarIds.first() + invisibleCalendarIds() val excluded = prefs.hiddenCalendarIds.first() +
prefs.pendingDisabledCalendarIds.first() +
invisibleCalendarIds()
dataSource.searchEvents(query) dataSource.searchEvents(query)
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } } .let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
} }
@@ -136,7 +185,20 @@ class CalendarRepositoryImpl @Inject constructor(
withContext(io) { dataSource.deleteCalendar(id) } withContext(io) { dataSource.deleteCalendar(id) }
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) = override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
withContext(io) { ids.forEach { dataSource.setCalendarVisible(it, visible) } } withContext(io) {
if (dataSource.canWriteCalendars()) {
ids.forEach { dataSource.setCalendarVisible(it, visible) }
// Nothing of ours is left waiting for the provider once the
// write lands (and switching one back on retires its entry).
prefs.removePendingDisabledCalendarIds(ids)
} else if (visible) {
prefs.removePendingDisabledCalendarIds(ids)
} else {
// Read-only permission: the switch still works, app-side, and
// the reconciler flushes it if WRITE_CALENDAR ever arrives.
prefs.addPendingDisabledCalendarIds(ids)
}
}
override suspend fun exportEvents(calendarIds: Set<Long>?) = override suspend fun exportEvents(calendarIds: Set<Long>?) =
withContext(io) { dataSource.exportableEvents(calendarIds) } withContext(io) { dataSource.exportableEvents(calendarIds) }

View File

@@ -1,85 +0,0 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject
import javax.inject.Singleton
/**
* One-shot reconciliation of the two visibility models Calendula used to run in
* parallel (#75): the app-local "disabled calendars" set is folded into the
* system's `Calendars.VISIBLE`, which from now on is the only one. See
* [calendarVisibilityPlan] for the rules — the app's state wins, so what the
* user sees today is exactly what they keep seeing after the upgrade.
*
* Runs at most once. Both permissions are required (the flag is a provider
* write), so a run that finds them missing changes nothing and leaves the guard
* unset — the next launch, or the moment the permission screen grants them,
* tries again. Only once the writes land is the guard set and the retired key
* dropped.
*/
@Singleton
class CalendarVisibilityMigration @Inject constructor(
@ApplicationContext private val context: Context,
private val dataSource: CalendarDataSource,
private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) {
suspend fun runIfNeeded() = withContext(io) {
if (prefs.visibilityMigrationDone.first()) return@withContext
if (!hasCalendarPermissions()) return@withContext
try {
val plan = calendarVisibilityPlan(
calendars = dataSource.calendars(),
disabledCalendarIds = prefs.legacyDisabledCalendarIds(),
)
// One calendar per write: the provider skips its reminder-alarm
// reschedule for anything but a single-id update (see
// [CalendarDataSource.setCalendarVisible]).
plan.hide.forEach { dataSource.setCalendarVisible(it, false) }
plan.show.forEach { dataSource.setCalendarVisible(it, true) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// A part-applied plan is safe to redo: every write is idempotent and
// the source set is still on disk, so leave the guard unset and try
// again next launch rather than stranding the user half-migrated.
Log.w(TAG, "Calendar visibility migration failed; will retry", e)
return@withContext
}
prefs.setVisibilityMigrationDone()
prefs.clearLegacyDisabledCalendarIds()
}
private fun hasCalendarPermissions(): Boolean =
listOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
/** Lets non-injectable entry points (the Application) reach the migration. */
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun calendarVisibilityMigration(): CalendarVisibilityMigration
}
private companion object {
const val TAG = "CalendarVisibilityMigration"
}
}

View File

@@ -0,0 +1,106 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject
import javax.inject.Singleton
/**
* Keeps the app's pending "switched off" set (see
* [CalendarPrefs.pendingDisabledCalendarIds]) and the system's
* `Calendars.VISIBLE` in step — the fold-in of the retired app-local visibility
* model (#75), and the standing drain for switch-offs made without
* `WRITE_CALENDAR`.
*
* Runs on every launch, and again the moment the permission screen grants the
* calendar permissions. It is a no-op whenever the pending set is empty, which
* is the steady state: each entry is written and dropped individually, so a run
* that dies part-way resumes exactly where it stopped and never re-applies a
* write the user has since undone by hand.
*
* The reconciliation only hides (see [calendarVisibilityPlan]). Calendars hidden
* at system level stay hidden, and the first run that sees one arms the one-time
* notice explaining why Calendula no longer lists their events.
*/
@Singleton
class CalendarVisibilityReconciler @Inject constructor(
@ApplicationContext private val context: Context,
private val dataSource: CalendarDataSource,
private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) {
suspend fun run() = withContext(io) {
// Everything, the DataStore reads included, sits inside the guard: this
// runs in a bare application-scope coroutine with no exception handler,
// so an IOException from a damaged preferences file would otherwise take
// the process down on every launch.
try {
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
val pending = prefs.pendingDisabledCalendarIds.first()
val calendars = dataSource.calendars()
armNoticeOnce(calendars, pending)
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
return@withContext
}
val plan = calendarVisibilityPlan(calendars, pending)
// Already off, or gone from the device — nothing to write, so let
// those ids leave the pending set with the rest.
prefs.removePendingDisabledCalendarIds(plan.settled)
// One calendar per write: the provider skips its reminder-alarm
// reschedule for anything but a single-id update (see
// [CalendarDataSource.setCalendarVisible]). Dropping each id as it
// lands keeps a part-applied run resumable.
for (id in plan.hide) {
dataSource.setCalendarVisible(id, false)
prefs.removePendingDisabledCalendarIds(setOf(id))
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Calendar visibility reconcile failed; will retry", e)
}
}
/**
* Arm the one-time notice if the device holds a calendar switched off
* outside Calendula. Evaluated once, on the first run that can read the
* calendars at all; the answer — including "nothing to say" — is stored, so
* the notice can't resurface later, when the same state would no longer be
* news to the user.
*/
private suspend fun armNoticeOnce(calendars: List<CalendarSource>, pending: Set<Long>) {
if (prefs.visibilityNoticePending.first() != null) return
prefs.setVisibilityNoticePending(hasSystemHiddenCalendars(calendars, pending))
}
private fun hasPermission(permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
/** Lets non-injectable entry points (the Application) reach the reconciler. */
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun calendarVisibilityReconciler(): CalendarVisibilityReconciler
}
private companion object {
const val TAG = "CalendarVisibility"
}
}

View File

@@ -1,22 +1,24 @@
package de.jeanlucmakiola.calendula.data.prefs package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* App-side preference for "calendars the user has hidden in this app" — the * App-side calendar preferences. [hiddenCalendarIds] is the drawer's filter
* drawer's filter sheet, a purely in-app declutter. It deliberately does *not* * sheet a purely in-app declutter that deliberately does *not* suppress
* suppress reminders; switching a calendar off entirely is the system's * reminders. Switching a calendar off entirely is the system's
* `Calendars.VISIBLE` flag, written straight to the provider (#75). * `Calendars.VISIBLE` flag, written straight to the provider (#75);
* [pendingDisabledCalendarIds] only holds those switch-offs the app has not been
* allowed to write yet.
* *
* Persisted as a comma-separated string of Long ids; non-numeric tokens are * Persisted as a comma-separated string of Long ids; non-numeric tokens are
* silently dropped (defensive — see CalendarPrefsTest). * silently dropped (defensive — see CalendarPrefsTest).
@@ -27,52 +29,58 @@ class CalendarPrefs @Inject constructor(
) { ) {
val hiddenCalendarIds: Flow<Set<Long>> = store.data.map { prefs -> val hiddenCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
prefs[HIDDEN_IDS_KEY].orEmpty() prefs[HIDDEN_IDS_KEY].parseIds()
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.toSet()
} }
suspend fun setHiddenCalendarIds(ids: Set<Long>) { suspend fun setHiddenCalendarIds(ids: Set<Long>) {
store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) }
}
/**
* Calendars switched off in Settings → Calendars that the provider does not
* know about yet. That switch writes the system's `Calendars.VISIBLE` (#75),
* which needs `WRITE_CALENDAR` — a user who granted read-only keeps their
* choice here instead, and so does everyone upgrading from the retired
* app-local model, whose set is read straight back out of the same key.
*
* Honoured as a display and reminder filter for as long as it is non-empty,
* so an un-flushable switch still does what the user asked. Not a second
* visibility model: `CalendarVisibilityReconciler` drains it into the
* provider entry by entry the moment the app may write, and nothing ever
* adds to it while it may.
*/
val pendingDisabledCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
prefs[DISABLED_IDS_KEY].parseIds()
}
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) =
editPendingDisabled { it + ids }
/**
* Drop [ids] from the pending set — one id at a time as the reconciler
* flushes it, so a run that fails part-way never re-applies what already
* landed (and can't undo a switch the user has since flipped by hand).
*/
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) =
editPendingDisabled { it - ids.toSet() }
private suspend fun editPendingDisabled(transform: (Set<Long>) -> Set<Long>) {
store.edit { prefs -> store.edit { prefs ->
if (ids.isEmpty()) { prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
prefs.remove(HIDDEN_IDS_KEY)
} else {
prefs[HIDDEN_IDS_KEY] = ids.sorted().joinToString(",")
}
} }
} }
/** /**
* The retired app-local "disabled calendars" set, readable only so the * Whether the one-time "visibility follows this device" notice is still
* one-shot visibility migration can reconcile it into `Calendars.VISIBLE` * owed. Null until the reconciler has evaluated it (which needs the calendar
* (see CalendarVisibilityMigration). Nothing else may consult it — it is a * permission), false once it has been shown or was never needed.
* second visibility model, which is exactly what #75 removed. Dropped by
* [clearLegacyDisabledCalendarIds] once the migration has run.
*/ */
suspend fun legacyDisabledCalendarIds(): Set<Long> = store.data.first().let { prefs -> val visibilityNoticePending: Flow<Boolean?> = store.data.map { prefs ->
prefs[DISABLED_IDS_KEY].orEmpty() prefs[VISIBILITY_NOTICE_KEY]
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.toSet()
} }
suspend fun clearLegacyDisabledCalendarIds() { suspend fun setVisibilityNoticePending(pending: Boolean) {
store.edit { prefs -> prefs.remove(DISABLED_IDS_KEY) } store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
}
/**
* Whether the one-shot reconciliation of the retired disabled set into
* `Calendars.VISIBLE` has run. Set only once it actually wrote (it needs
* `WRITE_CALENDAR`), so a run skipped for a missing permission is retried on
* the next launch.
*/
val visibilityMigrationDone: Flow<Boolean> = store.data.map { prefs ->
prefs[VISIBILITY_MIGRATION_KEY] == true
}
suspend fun setVisibilityMigrationDone() {
store.edit { prefs -> prefs[VISIBILITY_MIGRATION_KEY] = true }
} }
/** /**
@@ -90,7 +98,16 @@ class CalendarPrefs @Inject constructor(
companion object { companion object {
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids") internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids") internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
internal val VISIBILITY_MIGRATION_KEY = booleanPreferencesKey("visibility_migration_done") internal val VISIBILITY_NOTICE_KEY = booleanPreferencesKey("visibility_notice_pending")
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id") internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
} }
} }
private fun String?.parseIds(): Set<Long> = orEmpty()
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.toSet()
private fun MutablePreferences.writeIds(key: Preferences.Key<String>, ids: Set<Long>) {
if (ids.isEmpty()) remove(key) else set(key, ids.sorted().joinToString(","))
}

View File

@@ -28,7 +28,9 @@ import javax.inject.Inject
* *
* There is no per-calendar filtering here: a calendar switched off in * There is no per-calendar filtering here: a calendar switched off in
* Settings → Calendars has `Calendars.VISIBLE = 0`, and the provider creates no * Settings → Calendars has `Calendars.VISIBLE = 0`, and the provider creates no
* alert rows for it in the first place (#75). * alert rows for it in the first place (#75). The one case that flag can't
* cover — a read-only install, which keeps its switches app-side — is gated in
* [ReminderNotifier.post], where the snoozed re-show passes too.
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class EventReminderReceiver : BroadcastReceiver() { class EventReminderReceiver : BroadcastReceiver() {

View File

@@ -23,7 +23,9 @@ import javax.inject.Inject
* - **Snooze** cancels the notification and schedules an exact alarm to re-show * - **Snooze** cancels the notification and schedules an exact alarm to re-show
* it after the user's snooze delay. * it after the user's snooze delay.
* - **Show** (the alarm) re-posts the same notification, so the user can snooze * - **Show** (the alarm) re-posts the same notification, so the user can snooze
* or dismiss it again. * or dismiss it again — unless the calendar was switched off during the
* snooze, which [ReminderNotifier.post] catches (this alarm is ours, so no
* provider alert row stands between it and the notification).
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class ReminderActionReceiver : BroadcastReceiver() { class ReminderActionReceiver : BroadcastReceiver() {

View File

@@ -14,6 +14,8 @@ import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
@@ -37,6 +39,8 @@ import javax.inject.Singleton
class ReminderNotifier @Inject constructor( class ReminderNotifier @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val settingsPrefs: SettingsPrefs, private val settingsPrefs: SettingsPrefs,
private val calendarPrefs: CalendarPrefs,
private val calendarDataSource: CalendarDataSource,
) { ) {
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */ /** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
@@ -47,7 +51,21 @@ class ReminderNotifier @Inject constructor(
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled() return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
} }
/**
* The single choke point for "this calendar is switched off". The provider
* side needs no help — with `VISIBLE = 0` it creates no alert rows at all —
* but two paths reach [post] without one: a snooze we re-show from our own
* exact alarm, scheduled before the calendar was switched off, and a
* read-only install whose switch lives app-side ([CalendarPrefs]) because it
* may not write the flag. Both are covered here rather than in either
* receiver.
*/
private suspend fun isSilenced(calendarId: Long): Boolean =
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
calendarDataSource.isCalendarVisible(calendarId) == false
suspend fun post(alert: ReminderAlert) { suspend fun post(alert: ReminderAlert) {
if (isSilenced(alert.calendarId)) return
ensureChannel() ensureChannel()
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) } val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
val is24Hour = settingsPrefs.timeFormat.first() val is24Hour = settingsPrefs.timeFormat.first()

View File

@@ -1,45 +1,58 @@
package de.jeanlucmakiola.calendula.domain package de.jeanlucmakiola.calendula.domain
/** /**
* The `Calendars.VISIBLE` writes that bring the system flag in line with the * The `Calendars.VISIBLE` writes that flush the app's pending "switched off"
* app's retired disabled set. Empty when the two already agree. * set into the provider, plus the ids that need no write at all.
*/ */
data class CalendarVisibilityPlan( data class CalendarVisibilityPlan(
val show: Set<Long> = emptySet(),
val hide: Set<Long> = emptySet(), val hide: Set<Long> = emptySet(),
val settled: Set<Long> = emptySet(),
) { ) {
val isEmpty: Boolean get() = show.isEmpty() && hide.isEmpty() val isEmpty: Boolean get() = hide.isEmpty() && settled.isEmpty()
} }
/** /**
* Reconcile the two visibility models Calendula used to keep in parallel (#75), * Reconcile [pendingDisabledIds] — calendars switched off in Settings →
* with the app's state winning: * Calendars while the app could not write `Calendars.VISIBLE`, plus whatever
* the retired app-local visibility model left behind (#75) — against the
* calendars actually on the device.
* *
* - enabled in-app but hidden at system level → show it, *unless* the calendar * The plan only ever *hides*. Switching a calendar off is intent the user
* doesn't sync its events to the device ([CalendarSource.syncsEvents]). Those * expressed in Calendula, so carrying it into the provider is fair. The other
* hold no local events, so switching them on would produce nothing but an * direction is deliberately absent: a calendar hidden at system level was hidden
* "enabled yet permanently empty" row; left off, they read honestly as off. * somewhere else (another calendar app, the account's own settings), and
* - disabled in-app → hide it at system level, which is what stops the provider * switching it back on would un-hide it there too *and* start firing reminders
* scheduling its reminders. * nobody asked for. Calendula follows that flag instead and explains itself once
* - anything already in agreement is left untouched, so the migration writes as * (see [hasSystemHiddenCalendars]).
* little as possible.
* *
* App-state-wins is also what keeps the upgrade invisible: reconciling the other * [CalendarVisibilityPlan.settled] carries the ids that need no write — already
* way would make events the user sees today vanish. * hidden, or gone from the device. They leave the pending set exactly as a
* successful write would.
*/ */
fun calendarVisibilityPlan( fun calendarVisibilityPlan(
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
disabledCalendarIds: Set<Long>, pendingDisabledIds: Set<Long>,
): CalendarVisibilityPlan { ): CalendarVisibilityPlan {
val show = mutableSetOf<Long>() val byId = calendars.associateBy { it.id }
val hide = mutableSetOf<Long>() val hide = mutableSetOf<Long>()
for (calendar in calendars) { val settled = mutableSetOf<Long>()
val disabledInApp = calendar.id in disabledCalendarIds for (id in pendingDisabledIds) {
when { val calendar = byId[id]
disabledInApp && calendar.isVisibleInSystem -> hide += calendar.id // No row means the calendar is gone; already invisible means someone
!disabledInApp && !calendar.isVisibleInSystem && calendar.syncsEvents -> // (us, on an earlier run) got there first. Either way: nothing to write.
show += calendar.id if (calendar != null && calendar.isVisibleInSystem) hide += id else settled += id
} }
return CalendarVisibilityPlan(hide = hide, settled = settled)
} }
return CalendarVisibilityPlan(show = show, hide = hide)
} /**
* Whether any calendar is switched off at system level without Calendula having
* asked for it. Those calendars showed their events before the app adopted
* `Calendars.VISIBLE` as its one visibility model and no longer do, which is
* what the one-time notice explains — the alternative, switching them on, would
* reach into every other calendar app on the device.
*/
fun hasSystemHiddenCalendars(
calendars: List<CalendarSource>,
pendingDisabledIds: Set<Long>,
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }

View File

@@ -43,10 +43,11 @@ data class CalendarSource(
val isManaged: Boolean = false, val isManaged: Boolean = false,
/** /**
* Whether the provider keeps this calendar's events on the device * Whether the provider keeps this calendar's events on the device
* (`Calendars.SYNC_EVENTS`). Independent of [isVisibleInSystem]: a calendar * (`Calendars.SYNC_EVENTS`). Independent of [isVisibleInSystem]. For a
* with `syncsEvents = false` holds no local events at all, so making it * synced account it means the events aren't stored locally at all, so the
* visible cannot produce a single one — which is why the one-shot visibility * calendar reads as permanently empty; a device-local calendar another app
* migration leaves those calendars switched off. * created can hold events with the flag off, so it says nothing there.
* Read for the "not synced" row label (#76).
*/ */
val syncsEvents: Boolean = true, val syncsEvents: Boolean = true,
) )

View File

@@ -21,6 +21,8 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
@@ -78,6 +80,13 @@ fun RootScreen(
// frame instead of flashing the wrong screen. // frame instead of flashing the wrong screen.
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel() val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle() val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
// One-time explainer for the switch to the device's own calendar
// visibility (#75); armed by the reconciler, shown over the app.
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
if (onboardingDone == true && noticePending) {
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
}
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done -> Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
when (done) { when (done) {
true -> CalendarHost( true -> CalendarHost(

View File

@@ -0,0 +1,62 @@
package de.jeanlucmakiola.calendula.ui.calendars
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The one-time notice that Calendula now follows the device's per-calendar
* visibility (#75). Armed by `CalendarVisibilityReconciler` on the first launch
* that finds a calendar switched off outside the app — those used to show their
* events here and no longer do, and the app deliberately does not switch them
* back on, because that would un-hide them in every other calendar app too.
*/
@HiltViewModel
class CalendarVisibilityNoticeViewModel @Inject constructor(
private val prefs: CalendarPrefs,
) : ViewModel() {
val pending: StateFlow<Boolean> = prefs.visibilityNoticePending
.map { it == true }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
fun dismiss() {
viewModelScope.launch { prefs.setVisibilityNoticePending(false) }
}
}
/** Plain informational dialog — one acknowledgement, nothing to decide. */
@Composable
fun CalendarVisibilityNoticeDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.VisibilityOff, contentDescription = null) },
title = { Text(stringResource(R.string.calendars_visibility_notice_title)) },
text = { Text(stringResource(R.string.calendars_visibility_notice_message)) },
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_ok))
}
},
)
}

View File

@@ -278,7 +278,7 @@ private fun CalendarsList(
predictiveBack = true, predictiveBack = true,
) { ) {
// What the per-calendar / per-account switches below actually do. // What the per-calendar / per-account switches below actually do.
HintText(stringResource(R.string.calendars_disable_hint)) HintText(stringResource(R.string.calendars_visibility_hint))
// Local (device-only) calendars — one collapsible group. The header's // Local (device-only) calendars — one collapsible group. The header's
// "+" adds a calendar; the switch enables/disables them all at once; // "+" adds a calendar; the switch enables/disables them all at once;
@@ -708,7 +708,7 @@ private fun EnableSwitch(
enabled: Boolean, enabled: Boolean,
onToggle: (Boolean) -> Unit, onToggle: (Boolean) -> Unit,
) { ) {
val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName) val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
Switch( Switch(
checked = enabled, checked = enabled,
onCheckedChange = onToggle, onCheckedChange = onToggle,

View File

@@ -3,7 +3,7 @@ package de.jeanlucmakiola.calendula.ui.permission
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityMigration import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -12,7 +12,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class PermissionViewModel @Inject constructor( class PermissionViewModel @Inject constructor(
private val visibilityMigration: CalendarVisibilityMigration, private val visibilityReconciler: CalendarVisibilityReconciler,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow<PermissionUiState>(PermissionUiState.Rationale) private val _state = MutableStateFlow<PermissionUiState>(PermissionUiState.Rationale)
@@ -20,11 +20,10 @@ class PermissionViewModel @Inject constructor(
fun onGranted() { fun onGranted() {
_state.value = PermissionUiState.Granted _state.value = PermissionUiState.Granted
// The one-shot visibility migration needs the calendar permissions, so a // The visibility reconcile needs the calendar permissions, so the launch
// launch that started without them left it pending (#75) — this is the // that started without them skipped it (#75) — this is the moment it can
// moment it can finally run. A no-op once it has, and on a fresh install // finally run. A no-op on a fresh install, where nothing is pending.
// (nothing was ever disabled) it just stamps its guard. viewModelScope.launch { visibilityReconciler.run() }
viewModelScope.launch { visibilityMigration.runIfNeeded() }
} }
fun onDenied() { fun onDenied() {

View File

@@ -449,8 +449,6 @@
<string name="settings_special_dates_disable_type_message">Dadurch werden der Kalender „%1$s“ und seine Ereignisse gelöscht. Alle von dir hinzugefügten Erinnerungen oder Notizen gehen verloren.</string> <string name="settings_special_dates_disable_type_message">Dadurch werden der Kalender „%1$s“ und seine Ereignisse gelöscht. Alle von dir hinzugefügten Erinnerungen oder Notizen gehen verloren.</string>
<string name="settings_special_dates_disable_confirm">Deaktivieren</string> <string name="settings_special_dates_disable_confirm">Deaktivieren</string>
<string name="dialog_save">Speichern</string> <string name="dialog_save">Speichern</string>
<string name="calendars_disable_hint">Deaktiviere einen Kalender, um ihn aus der App zu entfernen seine Ereignisse, Filter und Auswahlmöglichkeiten. Es wird nichts gelöscht und du kannst ihn hier jederzeit wieder aktivieren.</string>
<string name="calendars_show_in_app_a11y">„%1$s“ in der App anzeigen</string>
<string name="calendars_account_menu_a11y">Weitere Optionen für %1$s</string> <string name="calendars_account_menu_a11y">Weitere Optionen für %1$s</string>
<string name="calendars_enable_all">Alle aktivieren</string> <string name="calendars_enable_all">Alle aktivieren</string>
<string name="calendars_disable_all">Alle deaktivieren</string> <string name="calendars_disable_all">Alle deaktivieren</string>

View File

@@ -333,8 +333,6 @@
<string name="calendars_local_header">Tus calendarios</string> <string name="calendars_local_header">Tus calendarios</string>
<string name="calendars_local_empty">Sin calendarios locales todavia. Crea uno para mantener eventos solo en este dispositivo.</string> <string name="calendars_local_empty">Sin calendarios locales todavia. Crea uno para mantener eventos solo en este dispositivo.</string>
<string name="calendars_add">Añadir calendario</string> <string name="calendars_add">Añadir calendario</string>
<string name="calendars_disable_hint">Desactiva un calendario para removerlo de la aplicación — sus eventos, filtros y selectores. Nada se eliminara, y puedes reactivarlo en cualquier momento.</string>
<string name="calendars_show_in_app_a11y">Mostrar \"%1$s\" en la aplicación</string>
<string name="calendars_synced_header">Calendarios sincronizados</string> <string name="calendars_synced_header">Calendarios sincronizados</string>
<string name="calendars_synced_hint">Estos provienen de cuentas en tu dispositivo. Crea o editalos en su propia aplicación.</string> <string name="calendars_synced_hint">Estos provienen de cuentas en tu dispositivo. Crea o editalos en su propia aplicación.</string>
<string name="calendars_manage_in_app">Gestionar en aplicación</string> <string name="calendars_manage_in_app">Gestionar en aplicación</string>

View File

@@ -400,8 +400,6 @@
<string name="calendars_local_header">Vos calendriers</string> <string name="calendars_local_header">Vos calendriers</string>
<string name="calendars_local_empty">Pas encore de calendrier local. Créez-en un pour conserver les événements uniquement sur cet appareil.</string> <string name="calendars_local_empty">Pas encore de calendrier local. Créez-en un pour conserver les événements uniquement sur cet appareil.</string>
<string name="calendars_add">Ajouter un calendrier</string> <string name="calendars_add">Ajouter un calendrier</string>
<string name="calendars_disable_hint">Désactivez un calendrier pour le retirer de lapplication, ses événements, ses filtres et ses sélecteurs. Rien nest supprimé et vous pouvez le réactiver à tout moment ici.</string>
<string name="calendars_show_in_app_a11y">Afficher « %1$s » dans lapplication</string>
<string name="calendars_synced_header">Calendriers synchronisés</string> <string name="calendars_synced_header">Calendriers synchronisés</string>
<string name="calendars_synced_hint">Ils proviennent de comptes sur votre appareil. Créez et modifiez-les dans leur propre application.</string> <string name="calendars_synced_hint">Ils proviennent de comptes sur votre appareil. Créez et modifiez-les dans leur propre application.</string>
<string name="calendars_manage_in_app">Gérer dans lapplication</string> <string name="calendars_manage_in_app">Gérer dans lapplication</string>

View File

@@ -317,8 +317,6 @@
<string name="calendars_local_header">Calendari locali</string> <string name="calendars_local_header">Calendari locali</string>
<string name="calendars_local_empty">Non ci sono ancora calendari locali. Creane uno con eventi salvati solo su questo dispositivo.</string> <string name="calendars_local_empty">Non ci sono ancora calendari locali. Creane uno con eventi salvati solo su questo dispositivo.</string>
<string name="calendars_add">Aggiungi calendario</string> <string name="calendars_add">Aggiungi calendario</string>
<string name="calendars_disable_hint">Disattiva un calendario per non vederlo più sull\'app. Non viene cancellato nulla, puoi riattivarlo in qualsiasi momento.</string>
<string name="calendars_show_in_app_a11y">Mostra \"%1$s\" nell\'app</string>
<string name="calendars_synced_header">Calendari sincronizzati</string> <string name="calendars_synced_header">Calendari sincronizzati</string>
<string name="calendars_synced_hint">Questi calendari vengono dagli account sincronizzati sul tuo dispositivo. Puoi modificarli o crearne di nuovi dall\'app di sincronizzazione.</string> <string name="calendars_synced_hint">Questi calendari vengono dagli account sincronizzati sul tuo dispositivo. Puoi modificarli o crearne di nuovi dall\'app di sincronizzazione.</string>
<string name="calendars_manage_in_app">Gestisci in app</string> <string name="calendars_manage_in_app">Gestisci in app</string>

View File

@@ -396,8 +396,6 @@
<string name="calendars_local_header">Twoje kalendarze</string> <string name="calendars_local_header">Twoje kalendarze</string>
<string name="calendars_local_empty">Brak lokalnych kalendarzy. Utwórz kalendarz, aby zapisywać wydarzenia tylko na tym urządzeniu.</string> <string name="calendars_local_empty">Brak lokalnych kalendarzy. Utwórz kalendarz, aby zapisywać wydarzenia tylko na tym urządzeniu.</string>
<string name="calendars_add">Dodaj kalendarz</string> <string name="calendars_add">Dodaj kalendarz</string>
<string name="calendars_disable_hint">Wyłącz kalendarz, aby ukryć go w aplikacji — wraz z jego wydarzeniami, filtrami i selektorami. Nic nie zostanie usunięte, a w każdej chwili możesz go tutaj ponownie włączyć.</string>
<string name="calendars_show_in_app_a11y">Pokaż „%1$s” w aplikacji</string>
<string name="calendars_synced_header">Synchronizowane kalendarze</string> <string name="calendars_synced_header">Synchronizowane kalendarze</string>
<string name="calendars_synced_hint">Pochodzą z kont na Twoim urządzeniu. Możesz je tworzyć i edytować w powiązanych z nimi aplikacjach.</string> <string name="calendars_synced_hint">Pochodzą z kont na Twoim urządzeniu. Możesz je tworzyć i edytować w powiązanych z nimi aplikacjach.</string>
<string name="calendars_manage_in_app">Zarządzaj w aplikacji</string> <string name="calendars_manage_in_app">Zarządzaj w aplikacji</string>

View File

@@ -473,8 +473,10 @@
<string name="calendars_local_header">Your calendars</string> <string name="calendars_local_header">Your calendars</string>
<string name="calendars_local_empty">No local calendars yet. Create one to keep events on this device only.</string> <string name="calendars_local_empty">No local calendars yet. Create one to keep events on this device only.</string>
<string name="calendars_add">Add calendar</string> <string name="calendars_add">Add calendar</string>
<string name="calendars_disable_hint">Turn a calendar off to hide it on this device — its events disappear from the app and it stops reminding you. Nothing is deleted, no other device is affected, and you can turn it back on here anytime.</string> <string name="calendars_visibility_hint">Turn a calendar off to hide it on this device — its events disappear from the app and it stops reminding you. This is the same switch your other calendar apps use, so they hide it too. Nothing is deleted, no other device is affected, and you can turn it back on here anytime.</string>
<string name="calendars_show_in_app_a11y">Show \"%1$s\"</string> <string name="calendars_visibility_a11y">Show \"%1$s\"</string>
<string name="calendars_visibility_notice_title">Some calendars are switched off</string>
<string name="calendars_visibility_notice_message">Calendula now shows the calendars that are switched on for this device, so what you see and what reminds you can no longer disagree. Some of yours are currently off — they were switched off here or in another calendar app. Turn any of them back on in Settings → Calendars.</string>
<string name="calendars_synced_header">Synced calendars</string> <string name="calendars_synced_header">Synced calendars</string>
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string> <string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
<string name="calendars_manage_in_app">Manage in app</string> <string name="calendars_manage_in_app">Manage in app</string>

View File

@@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
@@ -266,6 +267,122 @@ class CalendarRepositoryImplTest {
assertThat(fake.visibilityWrites).containsExactly(1L to false, 3L to false).inOrder() assertThat(fake.visibilityWrites).containsExactly(1L to false, 3L to false).inOrder()
} }
@Test
fun `without write permission the switch is kept app-side and still filters`(
@TempDir tempDir: Path,
) = runTest {
// READ granted, WRITE denied: the provider flag can't be written, so the
// choice is parked in the pending set — and honoured from there, or the
// user's switched-off calendars would come back on upgrade (#75).
val prefs = newPrefs(tempDir)
val fake = FakeCalendarDataSource().apply {
canWrite = false
calendarsResult = listOf(makeCal(1L), makeCal(2L))
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "A", calendarId = 1L),
makeEvent(11L, "B", calendarId = 2L),
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder()
repo.setCalendarsVisible(listOf(2L), false)
assertThat(awaitItem().map { it.title }).containsExactly("A")
assertThat(fake.visibilityWrites).isEmpty()
assertThat(prefs.pendingDisabledCalendarIds.first()).containsExactly(2L)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `calendars reports a pending switch-off as off`(@TempDir tempDir: Path) = runTest {
// Otherwise the Settings switch would snap straight back on for a
// read-only install, and the pickers would keep offering the calendar.
val prefs = newPrefs(tempDir)
val fake = FakeCalendarDataSource().apply {
canWrite = false
calendarsResult = listOf(makeCal(1L), makeCal(2L))
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
repo.calendars().test {
assertThat(awaitItem().map { it.isVisibleInSystem }).containsExactly(true, true)
repo.setCalendarsVisible(listOf(2L), false)
assertThat(awaitItem().single { it.id == 2L }.isVisibleInSystem).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `switching a calendar back on without write permission retires its entry`(
@TempDir tempDir: Path,
) = runTest {
val prefs = newPrefs(tempDir)
prefs.addPendingDisabledCalendarIds(setOf(2L))
val fake = FakeCalendarDataSource().apply {
canWrite = false
calendarsResult = listOf(makeCal(1L), makeCal(2L))
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
repo.setCalendarsVisible(listOf(2L), true)
assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
}
@Test
fun `a provider write clears anything still pending for that calendar`(
@TempDir tempDir: Path,
) = runTest {
val prefs = newPrefs(tempDir)
prefs.addPendingDisabledCalendarIds(setOf(2L))
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L))
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
repo.setCalendarsVisible(listOf(2L), false)
assertThat(fake.visibilityWrites).containsExactly(2L to false)
assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
}
@Test
fun `one tick costs one calendar query however many collectors there are`(
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L))
instancesResult = { _, _ -> listOf(makeEvent(10L, "A", calendarId = 1L)) }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.calendars().test {
awaitItem()
repo.instances(range).test {
awaitItem()
// Both flows listed/filtered off the same snapshot.
assertThat(fake.calendarQueries).isEqualTo(1)
cancelAndIgnoreRemainingEvents()
}
// The next tick invalidates it — one fresh read, not one per flow.
fake.tick()
awaitItem()
assertThat(fake.calendarQueries).isEqualTo(2)
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `searchEvents drops results from calendars that are off or hidden`( fun `searchEvents drops results from calendars that are off or hidden`(
@TempDir tempDir: Path, @TempDir tempDir: Path,

View File

@@ -61,7 +61,14 @@ internal class FakeCalendarDataSource : CalendarDataSource {
private val listeners = mutableListOf<() -> Unit>() private val listeners = mutableListOf<() -> Unit>()
override fun calendars(): List<CalendarSource> = calendarsResult /** How often [calendars] was queried — the repository shares one read per tick. */
var calendarQueries = 0
private set
override fun calendars(): List<CalendarSource> {
calendarQueries++
return calendarsResult
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> = override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
instancesResult(beginMillis, endMillis) instancesResult(beginMillis, endMillis)
override fun searchEvents(query: String): List<EventInstance> = searchResult(query) override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
@@ -108,6 +115,14 @@ internal class FakeCalendarDataSource : CalendarDataSource {
} }
} }
/** Whether the fake holds `WRITE_CALENDAR`; false models a read-only grant. */
var canWrite: Boolean = true
override fun canWriteCalendars(): Boolean = canWrite
override fun isCalendarVisible(id: Long): Boolean? =
calendarsResult.firstOrNull { it.id == id }?.isVisibleInSystem
override fun deleteCalendar(id: Long) { override fun deleteCalendar(id: Long) {
writeError?.let { throw it } writeError?.let { throw it }
deletedCalendarIds += id deletedCalendarIds += id

View File

@@ -52,51 +52,65 @@ class CalendarPrefsTest {
} }
@Test @Test
fun `legacy disabled set reads back what an older version stored`( fun `the pending disabled set reads back what an older version stored`(
@TempDir tempDir: Path, @TempDir tempDir: Path,
) = runTest { ) = runTest {
// Same key as the retired app-local "disabled calendars" model: an
// upgrade inherits that set as switch-offs still owed to the provider.
val store = newDataStore(tempDir) val store = newDataStore(tempDir)
val prefs = CalendarPrefs(store) val prefs = CalendarPrefs(store)
store.updateData { p -> store.updateData { p ->
p.toMutablePreferences().apply { this[CalendarPrefs.DISABLED_IDS_KEY] = "2,9" } p.toMutablePreferences().apply { this[CalendarPrefs.DISABLED_IDS_KEY] = "2,9" }
} }
assertThat(prefs.legacyDisabledCalendarIds()).isEqualTo(setOf(2L, 9L)) assertThat(prefs.pendingDisabledCalendarIds.first()).isEqualTo(setOf(2L, 9L))
} }
@Test @Test
fun `legacy disabled set is empty when nothing was ever stored`( fun `the pending disabled set is empty when nothing was ever stored`(
@TempDir tempDir: Path, @TempDir tempDir: Path,
) = runTest { ) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir)) val prefs = CalendarPrefs(newDataStore(tempDir))
assertThat(prefs.legacyDisabledCalendarIds()).isEmpty() assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
} }
@Test @Test
fun `clearing the legacy disabled set leaves the hidden set alone`( fun `pending ids are added and dropped one at a time`(@TempDir tempDir: Path) = runTest {
@TempDir tempDir: Path, val prefs = CalendarPrefs(newDataStore(tempDir))
) = runTest { prefs.addPendingDisabledCalendarIds(listOf(2L, 9L))
val store = newDataStore(tempDir) prefs.addPendingDisabledCalendarIds(listOf(4L))
val prefs = CalendarPrefs(store)
prefs.setHiddenCalendarIds(setOf(1L)) prefs.removePendingDisabledCalendarIds(setOf(9L))
store.updateData { p ->
p.toMutablePreferences().apply { this[CalendarPrefs.DISABLED_IDS_KEY] = "2" } assertThat(prefs.pendingDisabledCalendarIds.first()).isEqualTo(setOf(2L, 4L))
} }
prefs.clearLegacyDisabledCalendarIds() @Test
fun `draining the pending set leaves the hidden set alone`(
@TempDir tempDir: Path,
) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setHiddenCalendarIds(setOf(1L))
prefs.addPendingDisabledCalendarIds(setOf(2L))
assertThat(prefs.legacyDisabledCalendarIds()).isEmpty() prefs.removePendingDisabledCalendarIds(setOf(2L))
assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L)) assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L))
} }
@Test @Test
fun `visibility migration guard defaults to not-done and latches`( fun `the visibility notice is unevaluated until it is written`(
@TempDir tempDir: Path, @TempDir tempDir: Path,
) = runTest { ) = runTest {
// Null is what makes the evaluation one-shot: "no" is stored just as
// firmly as "yes", so the notice can't resurface on a later launch.
val prefs = CalendarPrefs(newDataStore(tempDir)) val prefs = CalendarPrefs(newDataStore(tempDir))
assertThat(prefs.visibilityMigrationDone.first()).isFalse() assertThat(prefs.visibilityNoticePending.first()).isNull()
prefs.setVisibilityMigrationDone() prefs.setVisibilityNoticePending(true)
assertThat(prefs.visibilityNoticePending.first()).isTrue()
assertThat(prefs.visibilityMigrationDone.first()).isTrue() prefs.setVisibilityNoticePending(false)
assertThat(prefs.visibilityNoticePending.first()).isFalse()
} }
} }

View File

@@ -4,53 +4,45 @@ import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
/** /**
* The one-shot reconciliation of the retired app-local "disabled calendars" set * Draining the app's pending "switched off" set into the system's
* into the system's `Calendars.VISIBLE` (#75), across every * `Calendars.VISIBLE` (#75) — including the set an upgrade inherits from the
* (sync_events, visible) combination. * retired app-local visibility model.
*/ */
class CalendarVisibilityPlanTest { class CalendarVisibilityPlanTest {
private fun cal( private fun cal(
id: Long, id: Long,
visible: Boolean = true, visible: Boolean = true,
local: Boolean = false,
syncsEvents: Boolean = true, syncsEvents: Boolean = true,
): CalendarSource = CalendarSource( ): CalendarSource = CalendarSource(
id = id, id = id,
displayName = "Cal $id", displayName = "Cal $id",
accountName = "acc@local", accountName = "acc@local",
accountType = "LOCAL", accountType = if (local) "LOCAL" else "com.google",
color = 0, color = 0,
isVisibleInSystem = visible, isVisibleInSystem = visible,
isLocal = local,
syncsEvents = syncsEvents, syncsEvents = syncsEvents,
) )
@Test @Test
fun `a calendar disabled in-app is switched off at system level`() { fun `a pending calendar is switched off at system level`() {
val plan = calendarVisibilityPlan(listOf(cal(1L, visible = true)), setOf(1L)) val plan = calendarVisibilityPlan(listOf(cal(1L, visible = true)), setOf(1L))
assertThat(plan.hide).containsExactly(1L) assertThat(plan.hide).containsExactly(1L)
assertThat(plan.show).isEmpty() assertThat(plan.settled).isEmpty()
} }
@Test @Test
fun `a calendar enabled in-app but hidden at system level is switched on`() { fun `a calendar hidden at system level is never switched on`() {
// The plan only hides: switching it on would un-hide the calendar in
// every other calendar app and start firing its reminders.
val plan = calendarVisibilityPlan(listOf(cal(1L, visible = false)), emptySet()) val plan = calendarVisibilityPlan(listOf(cal(1L, visible = false)), emptySet())
assertThat(plan.show).containsExactly(1L)
assertThat(plan.hide).isEmpty()
}
@Test
fun `a not-synced calendar is never switched on`() {
// sync_events = 0 means the provider holds none of its events locally,
// so making it visible could only produce an enabled-yet-empty row.
val plan = calendarVisibilityPlan(
listOf(cal(1L, visible = false, syncsEvents = false)),
emptySet(),
)
assertThat(plan.isEmpty).isTrue() assertThat(plan.isEmpty).isTrue()
} }
@Test @Test
fun `a not-synced calendar disabled in-app is still switched off`() { fun `a not-synced calendar is switched off like any other`() {
val plan = calendarVisibilityPlan( val plan = calendarVisibilityPlan(
listOf(cal(1L, visible = true, syncsEvents = false)), listOf(cal(1L, visible = true, syncsEvents = false)),
setOf(1L), setOf(1L),
@@ -59,38 +51,74 @@ class CalendarVisibilityPlanTest {
} }
@Test @Test
fun `calendars that already agree are left untouched`() { fun `a pending calendar already switched off is settled without a write`() {
val plan = calendarVisibilityPlan( val plan = calendarVisibilityPlan(listOf(cal(1L, visible = false)), setOf(1L))
listOf(cal(1L, visible = true), cal(2L, visible = false)), assertThat(plan.hide).isEmpty()
disabledCalendarIds = setOf(2L), assertThat(plan.settled).containsExactly(1L)
)
assertThat(plan.isEmpty).isTrue()
} }
@Test @Test
fun `a disabled id for a calendar that no longer exists writes nothing`() { fun `a pending id for a calendar that no longer exists is settled`() {
val plan = calendarVisibilityPlan(listOf(cal(1L)), setOf(99L)) val plan = calendarVisibilityPlan(listOf(cal(1L)), setOf(99L))
assertThat(plan.hide).isEmpty()
assertThat(plan.settled).containsExactly(99L)
}
@Test
fun `an empty pending set writes nothing`() {
val plan = calendarVisibilityPlan(listOf(cal(1L), cal(2L, visible = false)), emptySet())
assertThat(plan.isEmpty).isTrue() assertThat(plan.isEmpty).isTrue()
} }
@Test @Test
fun `a mixed device is reconciled in both directions at once`() { fun `a mixed device splits into writes and settled ids`() {
val plan = calendarVisibilityPlan( val plan = calendarVisibilityPlan(
listOf( listOf(
cal(1L, visible = true), // enabled, already visible → untouched cal(1L, visible = true), // not pending → untouched
cal(2L, visible = false), // enabled but hidden → show cal(2L, visible = false), // hidden elsewhere → untouched
cal(3L, visible = true), // disabled in-app → hide cal(3L, visible = true), // pending → hide
cal(4L, visible = false, syncsEvents = false), // not synced → untouched cal(4L, visible = false), // pending, already off → settled
), ),
disabledCalendarIds = setOf(3L), pendingDisabledIds = setOf(3L, 4L, 77L),
) )
assertThat(plan.show).containsExactly(2L)
assertThat(plan.hide).containsExactly(3L) assertThat(plan.hide).containsExactly(3L)
assertThat(plan.settled).containsExactly(4L, 77L)
} }
@Test @Test
fun `nothing disabled and everything visible is a no-op`() { fun `a calendar hidden outside the app arms the notice`() {
val plan = calendarVisibilityPlan(listOf(cal(1L), cal(2L)), emptySet()) assertThat(
assertThat(plan.isEmpty).isTrue() hasSystemHiddenCalendars(listOf(cal(1L), cal(2L, visible = false)), emptySet()),
).isTrue()
}
@Test
fun `a calendar we are about to hide ourselves does not arm the notice`() {
// It is off because the user switched it off here — nothing to explain.
assertThat(
hasSystemHiddenCalendars(listOf(cal(1L, visible = false)), setOf(1L)),
).isFalse()
}
@Test
fun `an all-visible device does not arm the notice`() {
assertThat(hasSystemHiddenCalendars(listOf(cal(1L), cal(2L)), emptySet())).isFalse()
}
@Test
fun `a device-local calendar is treated like any other`() {
// Its sync_events flag says nothing about whether it holds events, so
// neither the plan nor the notice may reason about it.
val plan = calendarVisibilityPlan(
listOf(cal(1L, visible = true, local = true, syncsEvents = false)),
setOf(1L),
)
assertThat(plan.hide).containsExactly(1L)
assertThat(
hasSystemHiddenCalendars(
listOf(cal(2L, visible = false, local = true, syncsEvents = false)),
emptySet(),
),
).isTrue()
} }
} }

View File

@@ -162,10 +162,17 @@ notifications never return because `FIRED` rows are never re-queried.
`Calendars.VISIBLE = 1`, so that flag *is* the app's on/off switch: Settings → `Calendars.VISIBLE = 1`, so that flag *is* the app's on/off switch: Settings →
Calendars writes it (one calendar per update — `CalendarProvider2` skips its own Calendars writes it (one calendar per update — `CalendarProvider2` skips its own
`checkNextAlarm()` reschedule for any selection that isn't `_id=`), and every `checkNextAlarm()` reschedule for any selection that isn't `_id=`), and every
display predicate reads `CalendarSource.isVisibleInSystem`. There is deliberately display predicate reads `CalendarSource.isVisibleInSystem`. The reconciliation
no second, app-local model and therefore nothing to suppress here. The drawer's runs one way only: a calendar the user switched off in Calendula is switched off
filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a separate in-app declutter in the provider, never the reverse — un-hiding one would reach into every other
that never touches reminders. See `docs/design/calendar-visibility-model.md`. calendar app on the device — and a one-time notice explains the calendars that
were already off. `CalendarPrefs.pendingDisabledCalendarIds` holds the switch-offs
the app has not been allowed to write yet (read-only permission grant, or a
pre-permission launch); `CalendarVisibilityReconciler` drains it entry by entry,
and until it does, the repository and `ReminderNotifier.post` honour it. That
gate also covers a snooze re-shown from our own alarm after its calendar was
switched off. The drawer's filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a
separate in-app declutter that never touches reminders.
Deliberately absent until real devices prove it necessary: own alarm Deliberately absent until real devices prove it necessary: own alarm
scheduling, `BOOT_COMPLETED`, snooze/dismiss actions, battery-exemption scheduling, `BOOT_COMPLETED`, snooze/dismiss actions, battery-exemption
prompts. prompts.