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
|
||||
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
|
||||
and what reminds you can no longer disagree. Your current selection is carried
|
||||
over on first launch, and calendars whose events aren't stored on this device
|
||||
are left as they are ([#75]).
|
||||
and what reminds you can no longer disagree ([#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
|
||||
tidies your view without silencing its reminders.
|
||||
|
||||
@@ -4,7 +4,7 @@ import android.app.Application
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
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.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||
@@ -40,21 +40,23 @@ class CalendulaApp : Application() {
|
||||
)
|
||||
reconcileAutoBackup()
|
||||
reconcileSpecialDates()
|
||||
migrateCalendarVisibility()
|
||||
reconcileCalendarVisibility()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the retired app-local "disabled calendars" set into the system's
|
||||
* `Calendars.VISIBLE` once (#75). A no-op after it has run, and on a fresh
|
||||
* install; a launch without the calendar permissions leaves it pending, and
|
||||
* granting them on the permission screen runs it there instead.
|
||||
* Flush any calendar switch-off the app hasn't been allowed to write into
|
||||
* the system's `Calendars.VISIBLE` yet — including the retired app-local
|
||||
* "disabled calendars" set the upgrade inherits (#75). A no-op on a fresh
|
||||
* 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(
|
||||
this, CalendarVisibilityMigration.Deps::class.java,
|
||||
this, CalendarVisibilityReconciler.Deps::class.java,
|
||||
)
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
deps.calendarVisibilityMigration().runIfNeeded()
|
||||
deps.calendarVisibilityReconciler().run()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,22 @@ interface CalendarDataSource {
|
||||
*/
|
||||
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]
|
||||
* (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")
|
||||
}
|
||||
|
||||
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 {
|
||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||
val values = ContentValues().apply {
|
||||
|
||||
@@ -44,6 +44,11 @@ interface CalendarRepository {
|
||||
* [CalendarDataSource.setCalendarVisible]. Each calendar is written on its
|
||||
* own, in order; a failure part-way leaves the earlier writes standing (the
|
||||
* 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)
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -47,23 +50,43 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
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 {
|
||||
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>> =
|
||||
ticks
|
||||
.onStart { emit(Unit) }
|
||||
.reQuery { dataSource.calendars() }
|
||||
.flowOn(io)
|
||||
combine(
|
||||
ticks.onStart { emit(Unit) }.reQuery { calendarsSnapshot() },
|
||||
prefs.pendingDisabledCalendarIds,
|
||||
) { 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
|
||||
// app-side hidden set: an event is dropped when the user switched its
|
||||
// calendar off in Settings → Calendars (which also stops the provider
|
||||
// scheduling its reminders) *or* hid it in the filter sheet. Re-runs when
|
||||
// the provider ticks — writing VISIBLE notifies, so switching a calendar
|
||||
// updates every view — or when the hidden set changes. [calendars] stays
|
||||
// unfiltered so those screens can list and re-enable invisible calendars.
|
||||
// switch-offs still waiting to be written to it ∪ the app-side hidden set:
|
||||
// an event is dropped when the user switched its calendar off in Settings →
|
||||
// Calendars (which also stops the provider scheduling its reminders) *or*
|
||||
// hid it in the filter sheet. Re-runs when the provider ticks — writing
|
||||
// VISIBLE notifies, so switching a calendar updates every view — or when
|
||||
// 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>> =
|
||||
combine(
|
||||
ticks
|
||||
@@ -80,8 +103,9 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
)
|
||||
},
|
||||
prefs.hiddenCalendarIds,
|
||||
) { queried, hidden ->
|
||||
val excluded = hidden + queried.invisibleCalendarIds
|
||||
prefs.pendingDisabledCalendarIds,
|
||||
) { queried, hidden, pendingDisabled ->
|
||||
val excluded = hidden + pendingDisabled + queried.invisibleCalendarIds
|
||||
if (excluded.isEmpty()) queried.instances
|
||||
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. */
|
||||
private fun invisibleCalendarIds(): Set<Long> = dataSource.calendars()
|
||||
private suspend fun invisibleCalendarIds(): Set<Long> = calendarsSnapshot()
|
||||
.filterNot { it.isVisibleInSystem }
|
||||
.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) {
|
||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||
}
|
||||
|
||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||
if (query.isBlank()) return@withContext emptyList()
|
||||
val excluded = prefs.hiddenCalendarIds.first() + invisibleCalendarIds()
|
||||
val excluded = prefs.hiddenCalendarIds.first() +
|
||||
prefs.pendingDisabledCalendarIds.first() +
|
||||
invisibleCalendarIds()
|
||||
dataSource.searchEvents(query)
|
||||
.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) }
|
||||
|
||||
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>?) =
|
||||
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
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* App-side preference for "calendars the user has hidden in this app" — the
|
||||
* drawer's filter sheet, a purely in-app declutter. It deliberately does *not*
|
||||
* suppress reminders; switching a calendar off entirely is the system's
|
||||
* `Calendars.VISIBLE` flag, written straight to the provider (#75).
|
||||
* App-side calendar preferences. [hiddenCalendarIds] is the drawer's filter
|
||||
* sheet — a purely in-app declutter that deliberately does *not* suppress
|
||||
* reminders. Switching a calendar off entirely is the system's
|
||||
* `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
|
||||
* silently dropped (defensive — see CalendarPrefsTest).
|
||||
@@ -27,52 +29,58 @@ class CalendarPrefs @Inject constructor(
|
||||
) {
|
||||
|
||||
val hiddenCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
||||
prefs[HIDDEN_IDS_KEY].orEmpty()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.toSet()
|
||||
prefs[HIDDEN_IDS_KEY].parseIds()
|
||||
}
|
||||
|
||||
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 ->
|
||||
if (ids.isEmpty()) {
|
||||
prefs.remove(HIDDEN_IDS_KEY)
|
||||
} else {
|
||||
prefs[HIDDEN_IDS_KEY] = ids.sorted().joinToString(",")
|
||||
}
|
||||
prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The retired app-local "disabled calendars" set, readable only so the
|
||||
* one-shot visibility migration can reconcile it into `Calendars.VISIBLE`
|
||||
* (see CalendarVisibilityMigration). Nothing else may consult it — it is a
|
||||
* second visibility model, which is exactly what #75 removed. Dropped by
|
||||
* [clearLegacyDisabledCalendarIds] once the migration has run.
|
||||
* Whether the one-time "visibility follows this device" notice is still
|
||||
* owed. Null until the reconciler has evaluated it (which needs the calendar
|
||||
* permission), false once it has been shown or was never needed.
|
||||
*/
|
||||
suspend fun legacyDisabledCalendarIds(): Set<Long> = store.data.first().let { prefs ->
|
||||
prefs[DISABLED_IDS_KEY].orEmpty()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.toSet()
|
||||
val visibilityNoticePending: Flow<Boolean?> = store.data.map { prefs ->
|
||||
prefs[VISIBILITY_NOTICE_KEY]
|
||||
}
|
||||
|
||||
suspend fun clearLegacyDisabledCalendarIds() {
|
||||
store.edit { prefs -> prefs.remove(DISABLED_IDS_KEY) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
suspend fun setVisibilityNoticePending(pending: Boolean) {
|
||||
store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,7 +98,16 @@ class CalendarPrefs @Inject constructor(
|
||||
companion object {
|
||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_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")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
* 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
|
||||
class EventReminderReceiver : BroadcastReceiver() {
|
||||
|
||||
@@ -23,7 +23,9 @@ import javax.inject.Inject
|
||||
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
||||
* it after the user's snooze delay.
|
||||
* - **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
|
||||
class ReminderActionReceiver : BroadcastReceiver() {
|
||||
|
||||
@@ -14,6 +14,8 @@ import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
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.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
@@ -37,6 +39,8 @@ import javax.inject.Singleton
|
||||
class ReminderNotifier @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
) {
|
||||
|
||||
/** 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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (isSilenced(alert.calendarId)) return
|
||||
ensureChannel()
|
||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
|
||||
@@ -1,45 +1,58 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
/**
|
||||
* The `Calendars.VISIBLE` writes that bring the system flag in line with the
|
||||
* app's retired disabled set. Empty when the two already agree.
|
||||
* The `Calendars.VISIBLE` writes that flush the app's pending "switched off"
|
||||
* set into the provider, plus the ids that need no write at all.
|
||||
*/
|
||||
data class CalendarVisibilityPlan(
|
||||
val show: 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),
|
||||
* with the app's state winning:
|
||||
* Reconcile [pendingDisabledIds] — calendars switched off in Settings →
|
||||
* 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
|
||||
* doesn't sync its events to the device ([CalendarSource.syncsEvents]). Those
|
||||
* hold no local events, so switching them on would produce nothing but an
|
||||
* "enabled yet permanently empty" row; left off, they read honestly as off.
|
||||
* - disabled in-app → hide it at system level, which is what stops the provider
|
||||
* scheduling its reminders.
|
||||
* - anything already in agreement is left untouched, so the migration writes as
|
||||
* little as possible.
|
||||
* The plan only ever *hides*. Switching a calendar off is intent the user
|
||||
* expressed in Calendula, so carrying it into the provider is fair. The other
|
||||
* direction is deliberately absent: a calendar hidden at system level was hidden
|
||||
* somewhere else (another calendar app, the account's own settings), and
|
||||
* switching it back on would un-hide it there too *and* start firing reminders
|
||||
* nobody asked for. Calendula follows that flag instead and explains itself once
|
||||
* (see [hasSystemHiddenCalendars]).
|
||||
*
|
||||
* App-state-wins is also what keeps the upgrade invisible: reconciling the other
|
||||
* way would make events the user sees today vanish.
|
||||
* [CalendarVisibilityPlan.settled] carries the ids that need no write — already
|
||||
* hidden, or gone from the device. They leave the pending set exactly as a
|
||||
* successful write would.
|
||||
*/
|
||||
fun calendarVisibilityPlan(
|
||||
calendars: List<CalendarSource>,
|
||||
disabledCalendarIds: Set<Long>,
|
||||
pendingDisabledIds: Set<Long>,
|
||||
): CalendarVisibilityPlan {
|
||||
val show = mutableSetOf<Long>()
|
||||
val byId = calendars.associateBy { it.id }
|
||||
val hide = mutableSetOf<Long>()
|
||||
for (calendar in calendars) {
|
||||
val disabledInApp = calendar.id in disabledCalendarIds
|
||||
when {
|
||||
disabledInApp && calendar.isVisibleInSystem -> hide += calendar.id
|
||||
!disabledInApp && !calendar.isVisibleInSystem && calendar.syncsEvents ->
|
||||
show += calendar.id
|
||||
}
|
||||
val settled = mutableSetOf<Long>()
|
||||
for (id in pendingDisabledIds) {
|
||||
val calendar = byId[id]
|
||||
// No row means the calendar is gone; already invisible means someone
|
||||
// (us, on an earlier run) got there first. Either way: nothing to write.
|
||||
if (calendar != null && calendar.isVisibleInSystem) hide += id else settled += id
|
||||
}
|
||||
return CalendarVisibilityPlan(show = show, hide = hide)
|
||||
return CalendarVisibilityPlan(hide = hide, settled = settled)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
/**
|
||||
* Whether the provider keeps this calendar's events on the device
|
||||
* (`Calendars.SYNC_EVENTS`). Independent of [isVisibleInSystem]: a calendar
|
||||
* with `syncsEvents = false` holds no local events at all, so making it
|
||||
* visible cannot produce a single one — which is why the one-shot visibility
|
||||
* migration leaves those calendars switched off.
|
||||
* (`Calendars.SYNC_EVENTS`). Independent of [isVisibleInSystem]. For a
|
||||
* synced account it means the events aren't stored locally at all, so the
|
||||
* calendar reads as permanently empty; a device-local calendar another app
|
||||
* 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,
|
||||
)
|
||||
|
||||
@@ -21,6 +21,8 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
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.ReminderOnboardingScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||
@@ -78,6 +80,13 @@ fun RootScreen(
|
||||
// frame instead of flashing the wrong screen.
|
||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||
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 ->
|
||||
when (done) {
|
||||
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,
|
||||
) {
|
||||
// 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
|
||||
// "+" adds a calendar; the switch enables/disables them all at once;
|
||||
@@ -708,7 +708,7 @@ private fun EnableSwitch(
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName)
|
||||
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
|
||||
@@ -3,7 +3,7 @@ package de.jeanlucmakiola.calendula.ui.permission
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -12,7 +12,7 @@ import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class PermissionViewModel @Inject constructor(
|
||||
private val visibilityMigration: CalendarVisibilityMigration,
|
||||
private val visibilityReconciler: CalendarVisibilityReconciler,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<PermissionUiState>(PermissionUiState.Rationale)
|
||||
@@ -20,11 +20,10 @@ class PermissionViewModel @Inject constructor(
|
||||
|
||||
fun onGranted() {
|
||||
_state.value = PermissionUiState.Granted
|
||||
// The one-shot visibility migration needs the calendar permissions, so a
|
||||
// launch that started without them left it pending (#75) — this is the
|
||||
// moment it can finally run. A no-op once it has, and on a fresh install
|
||||
// (nothing was ever disabled) it just stamps its guard.
|
||||
viewModelScope.launch { visibilityMigration.runIfNeeded() }
|
||||
// The visibility reconcile needs the calendar permissions, so the launch
|
||||
// that started without them skipped it (#75) — this is the moment it can
|
||||
// finally run. A no-op on a fresh install, where nothing is pending.
|
||||
viewModelScope.launch { visibilityReconciler.run() }
|
||||
}
|
||||
|
||||
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_confirm">Deaktivieren</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_enable_all">Alle aktivieren</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_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_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_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>
|
||||
|
||||
@@ -400,8 +400,6 @@
|
||||
<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_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_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>
|
||||
|
||||
@@ -317,8 +317,6 @@
|
||||
<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_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_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>
|
||||
|
||||
@@ -396,8 +396,6 @@
|
||||
<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_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_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>
|
||||
|
||||
@@ -473,8 +473,10 @@
|
||||
<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_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_show_in_app_a11y">Show \"%1$s\"</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_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_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>
|
||||
|
||||
@@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
@@ -266,6 +267,122 @@ class CalendarRepositoryImplTest {
|
||||
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
|
||||
fun `searchEvents drops results from calendars that are off or hidden`(
|
||||
@TempDir tempDir: Path,
|
||||
|
||||
@@ -61,7 +61,14 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
|
||||
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> =
|
||||
instancesResult(beginMillis, endMillis)
|
||||
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) {
|
||||
writeError?.let { throw it }
|
||||
deletedCalendarIds += id
|
||||
|
||||
@@ -52,51 +52,65 @@ class CalendarPrefsTest {
|
||||
}
|
||||
|
||||
@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,
|
||||
) = 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 prefs = CalendarPrefs(store)
|
||||
store.updateData { p ->
|
||||
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
|
||||
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,
|
||||
) = runTest {
|
||||
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.legacyDisabledCalendarIds()).isEmpty()
|
||||
assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
|
||||
}
|
||||
|
||||
@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 {
|
||||
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||
prefs.addPendingDisabledCalendarIds(listOf(2L, 9L))
|
||||
prefs.addPendingDisabledCalendarIds(listOf(4L))
|
||||
|
||||
prefs.removePendingDisabledCalendarIds(setOf(9L))
|
||||
|
||||
assertThat(prefs.pendingDisabledCalendarIds.first()).isEqualTo(setOf(2L, 4L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `draining the pending set leaves the hidden set alone`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = CalendarPrefs(store)
|
||||
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||
prefs.setHiddenCalendarIds(setOf(1L))
|
||||
store.updateData { p ->
|
||||
p.toMutablePreferences().apply { this[CalendarPrefs.DISABLED_IDS_KEY] = "2" }
|
||||
}
|
||||
prefs.addPendingDisabledCalendarIds(setOf(2L))
|
||||
|
||||
prefs.clearLegacyDisabledCalendarIds()
|
||||
prefs.removePendingDisabledCalendarIds(setOf(2L))
|
||||
|
||||
assertThat(prefs.legacyDisabledCalendarIds()).isEmpty()
|
||||
assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
|
||||
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visibility migration guard defaults to not-done and latches`(
|
||||
fun `the visibility notice is unevaluated until it is written`(
|
||||
@TempDir tempDir: Path,
|
||||
) = 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))
|
||||
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
|
||||
|
||||
/**
|
||||
* The one-shot reconciliation of the retired app-local "disabled calendars" set
|
||||
* into the system's `Calendars.VISIBLE` (#75), across every
|
||||
* (sync_events, visible) combination.
|
||||
* Draining the app's pending "switched off" set into the system's
|
||||
* `Calendars.VISIBLE` (#75) — including the set an upgrade inherits from the
|
||||
* retired app-local visibility model.
|
||||
*/
|
||||
class CalendarVisibilityPlanTest {
|
||||
|
||||
private fun cal(
|
||||
id: Long,
|
||||
visible: Boolean = true,
|
||||
local: Boolean = false,
|
||||
syncsEvents: Boolean = true,
|
||||
): CalendarSource = CalendarSource(
|
||||
id = id,
|
||||
displayName = "Cal $id",
|
||||
accountName = "acc@local",
|
||||
accountType = "LOCAL",
|
||||
accountType = if (local) "LOCAL" else "com.google",
|
||||
color = 0,
|
||||
isVisibleInSystem = visible,
|
||||
isLocal = local,
|
||||
syncsEvents = syncsEvents,
|
||||
)
|
||||
|
||||
@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))
|
||||
assertThat(plan.hide).containsExactly(1L)
|
||||
assertThat(plan.show).isEmpty()
|
||||
assertThat(plan.settled).isEmpty()
|
||||
}
|
||||
|
||||
@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())
|
||||
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()
|
||||
}
|
||||
|
||||
@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(
|
||||
listOf(cal(1L, visible = true, syncsEvents = false)),
|
||||
setOf(1L),
|
||||
@@ -59,38 +51,74 @@ class CalendarVisibilityPlanTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendars that already agree are left untouched`() {
|
||||
val plan = calendarVisibilityPlan(
|
||||
listOf(cal(1L, visible = true), cal(2L, visible = false)),
|
||||
disabledCalendarIds = setOf(2L),
|
||||
)
|
||||
assertThat(plan.isEmpty).isTrue()
|
||||
fun `a pending calendar already switched off is settled without a write`() {
|
||||
val plan = calendarVisibilityPlan(listOf(cal(1L, visible = false)), setOf(1L))
|
||||
assertThat(plan.hide).isEmpty()
|
||||
assertThat(plan.settled).containsExactly(1L)
|
||||
}
|
||||
|
||||
@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))
|
||||
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()
|
||||
}
|
||||
|
||||
@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(
|
||||
listOf(
|
||||
cal(1L, visible = true), // enabled, already visible → untouched
|
||||
cal(2L, visible = false), // enabled but hidden → show
|
||||
cal(3L, visible = true), // disabled in-app → hide
|
||||
cal(4L, visible = false, syncsEvents = false), // not synced → untouched
|
||||
cal(1L, visible = true), // not pending → untouched
|
||||
cal(2L, visible = false), // hidden elsewhere → untouched
|
||||
cal(3L, visible = true), // pending → hide
|
||||
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.settled).containsExactly(4L, 77L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing disabled and everything visible is a no-op`() {
|
||||
val plan = calendarVisibilityPlan(listOf(cal(1L), cal(2L)), emptySet())
|
||||
assertThat(plan.isEmpty).isTrue()
|
||||
fun `a calendar hidden outside the app arms the notice`() {
|
||||
assertThat(
|
||||
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 writes it (one calendar per update — `CalendarProvider2` skips its own
|
||||
`checkNextAlarm()` reschedule for any selection that isn't `_id=`), and every
|
||||
display predicate reads `CalendarSource.isVisibleInSystem`. There is deliberately
|
||||
no second, app-local model and therefore nothing to suppress here. The drawer's
|
||||
filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a separate in-app declutter
|
||||
that never touches reminders. See `docs/design/calendar-visibility-model.md`.
|
||||
display predicate reads `CalendarSource.isVisibleInSystem`. The reconciliation
|
||||
runs one way only: a calendar the user switched off in Calendula is switched off
|
||||
in the provider, never the reverse — un-hiding one would reach into every other
|
||||
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
|
||||
scheduling, `BOOT_COMPLETED`, snooze/dismiss actions, battery-exemption
|
||||
prompts.
|
||||
|
||||
Reference in New Issue
Block a user