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:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(","))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 l’application, ses événements, ses filtres et ses sélecteurs. Rien n’est supprimé et vous pouvez le réactiver à tout moment ici.</string>
|
|
||||||
<string name="calendars_show_in_app_a11y">Afficher « %1$s » dans l’application</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 l’application</string>
|
<string name="calendars_manage_in_app">Gérer dans l’application</string>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user