Compare commits

...

13 Commits

Author SHA1 Message Date
b91c13030b feat(reminders): schedule and fire reminders in-house (#75)
Delivery no longer waits to be told. The scan reads `Instances` and `Reminders`,
plans every reminder, posts what has come due and arms one exact alarm for the
next — replacing both halves of what the provider used to do for us.

Reacting to `EVENT_REMINDER` could not be made reliable, only more hopeful. An
app that reacts cannot distinguish "nothing was due" from "the broadcast never
came", and AOSP's own unbundled calendar carries three workarounds for OEM
providers that retarget it or write the alert row late. Etar's fallback was not
the answer either: it replaces the alarm but still reads `CalendarAlerts` for
what to show, and it disables itself the moment one real broadcast arrives —
useless against a broadcast that arrives with no row behind it.

Keeping the old receiver alongside was rejected for the same reason it looks
attractive: on a healthy device both paths fire, and there is no honest way to
suppress one without the latch we just ruled out. So the provider path goes —
`EventReminderReceiver`, `ReminderAlertStore`, `ReminderRecovery` and the
`CalendarAlerts` writes with it. Reminder delivery no longer needs WRITE_CALENDAR.

One alarm exists at a time, re-planned on every firing, so an edit needs no alarm
bookkeeping to stay in sync. Every trigger runs the same idempotent scan: the
alarm, boot and package-replace (both wipe pending alarms), clock and timezone
changes, a provider change while the app is up, launch, and a daily worker for a
device that drops the alarm with nothing to announce it. RECEIVE_BOOT_COMPLETED
is new and load-bearing — without it reminders stop dead after a restart.

`ReminderAlert.alertId` becomes `key`, derived from the reminder rather than a
row id that no longer exists, and the notification tag and PendingIntent request
codes ride on it, so a re-posted reminder still replaces itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:25:51 +02:00
40096c473e fix(reminders): fire all-day reminders at the hour the setting names (#75)
Taking the stored offset at face value is what puts an all-day reminder on the
wrong hour, and owning the alarm is what finally allows fixing it.

An all-day occurrence begins at UTC midnight, and the offset in `Reminders` is
not a plain lead time: `AllDayReminderEncoding` folds the wanted wall-clock hour
into it, sampled against one date's UTC offset. Fire at `begin - minutes` and
every occurrence in a different DST phase than the sampled one drifts by the
offset delta — an hour early one way, an hour late the other, which for a yearly
birthday is every second occurrence. Its KDoc documents that drift as inherent to
the provider model. It was, while the provider held the alarm.

Rows written by other calendar apps have the opposite problem: a conventional
1440 carries no hour at all, so it fires at UTC midnight — 02:00 local in summer
Berlin, and a day early west of UTC, where UTC midnight still falls on the
previous local date.

So the offset is now read only for which day it means, and the hour comes from
the one global all-day reminder setting, recomposed against each occurrence's own
date. Plain multiples of 1440 are read at face value, which covers foreign rows
and stays right for our own rows that land on a multiple; anything else keeps the
local-date recovery `fromProviderAllDayMinutes` already uses for display, so the
notification arrives on the day the event screen promises.

Timed reminders are untouched: `begin` is an absolute instant, so `begin -
minutes` is exact in any zone across any boundary. A test pins that too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:10:17 +02:00
29857495be feat(reminders): work out reminder times ourselves (#75)
The reporter's silent events sit in a calendar Calendula created itself, so
`Calendars.VISIBLE` is 1 and the visibility fix cannot be what they hit. What is
left is the class AOSP's own unbundled calendar already carries three
workarounds for: OEM providers that retarget the `EVENT_REMINDER` broadcast, or
that only write the `CalendarAlerts` row at alert time. An app that can only
react to that broadcast cannot tell "no reminder was due" from "the broadcast
never came", so reacting has to stop being the whole design.

This is the decision layer, kept pure and off Android: reminder offsets and
occurrences in, fire instants out, plus which of them a given scan owes.

The watermark is what replaces the provider's `STATE_FIRED` bookkeeping. Due
means the fire instant falls in `(lastFired, now]` — half-open, so a scan that
runs twice cannot post the same reminder twice, while a scan that runs late
still posts everything the missed alarm would have. A reboot or an app update
that drops our alarm therefore costs nothing.

All-day offsets are measured from the raw `begin` with no timezone correction,
because `AllDayReminderEncoding` already folded the wanted wall-clock time into
the stored offset measured from exactly that UTC midnight.

The query horizon stretches past the longest offset any reminder row carries, so
a "two weeks before" reminder is planned before it comes due rather than firing
late — the limitation Etar's equivalent documents and lives with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 18:48:10 +02:00
bf6415c023 Merge pull request 'fix(calendars): one visibility model, so reminders can't silently go missing (#75)' (!97) from fix/calendar-visibility-model into release/v2.17.0
Reviewed-on: #97
2026-07-25 20:03:14 +00:00
7aef01d95e docs(architecture): record what the second review pass changed
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 10m57s
The visibility section claimed the reminder side needed no per-calendar
handling. It does on the one path where VISIBLE was never written: an alert the
notifier silences keeps its SCHEDULED state while its event is still ahead, and
switching the calendar back on re-posts it, so the provider's own table is the
stash the deleted SuppressedReminderStore used to be.

Also rewraps the paragraph and separates it from the one that follows, which it
had been running into since the section was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:38:34 +02:00
ce4d6bc4d1 fix(calendars): keep an event's own calendar when it is switched off
Excluding switched-off calendars from the event form's picker is right for
*targets*, but it also dropped the calendar an event already lives in. Editing
an event of a writable calendar switched off on this device (from a widget, a
deep link, another app's ACTION_EDIT) rendered the calendar row as the red "no
calendar" error, with the picker still enabled — so any pick turned the save
into a calendar move nobody asked for. The event's own calendar is added back
whenever it isn't among the targets, the way the managed special-dates case
already did; a calendar the app may not write to is still no target.

Settings → Notifications had missed the same predicate swap: it kept offering
per-calendar reminder overrides for switched-off calendars, where the provider
schedules no alarms and the setting could never fire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:34:24 +02:00
4ad805e747 fix(calendars): don't flash a flushed calendar's events back on
The reconciler writes VISIBLE=0 and then releases the id from the pending set,
but the ContentObserver that invalidates the repository's cached calendar
snapshot is dispatched through the main looper and arrives later. Until it did,
the released set was read against the snapshot from before the write: the
calendar reported as on again and instances re-admitted exactly the events the
migration was hiding — on a cold start, for as long as the busy main thread took.

The snapshot cache is keyed on the pending set as well as the tick now, so any
read that sees a changed set re-queries the provider; a change to the set also
re-runs the flows, and the pending ids are read in the same pass as the
calendars rather than combined in from a live flow. Both id sets are deduped at
the prefs seam (the store is shared with SettingsPrefs, so every unrelated write
re-emitted them) and calendars() collapses identical lists, which keeps those
re-queries as rare as they should be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:34:24 +02:00
bb6e3ad336 fix(calendars): only tell upgrades about the visibility change, and reconcile on every grant
Two holes in the reconciler, both found by review.

The one-time notice armed on any device holding a calendar at VISIBLE=0, which
is the norm on a fresh install: a second account's calendars, "Holidays in X", a
subscribed calendar. A brand-new user got a changelog dialog about a migration
they never experienced, right after onboarding. It is gated on
firstInstallTime != lastUpdateTime now, and a fresh install retires the notice
unshown ahead of the permission check — so an update installed before the first
grant can't make it look like an upgrade afterwards.

The catch-up run hung off PermissionViewModel.onGranted, which only fires for the
in-app request. Granting from Android's app-settings screen comes back through
RootScreen's ON_RESUME, so an upgrading user who took that route kept their
inherited switch-offs unflushed — their events filtered app-side while the
provider went on scheduling the reminders they asked to stop. The trigger sits on
RootScreen showing the app instead, which covers both routes. To keep that cheap,
a settled run now returns after two DataStore reads instead of querying every
calendar first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:34:24 +02:00
edbeadfa30 fix(reminders): stop marking a silenced reminder handled and losing it
"With VISIBLE=0 the provider creates no alert rows" justified deleting the
suppression stash, but it only holds where the flag was actually written. Where
the switch lives in pendingDisabledCalendarIds — a read-only install, or an
upgrade whose flush hasn't landed — the provider still holds VISIBLE=1 and keeps
creating and broadcasting rows. The receiver silenced those in
ReminderNotifier.post and then marked the whole due batch STATE_FIRED, and
dueAlerts only ever returns STATE_SCHEDULED, so switching the calendar back on
before the event could no longer surface the reminder: it was gone.

post() now reports whether it put a notification up, and the receiver marks what
it posted plus what it silenced for an event already over (handledAlertIds). A
silenced alert for an event still ahead stays scheduled, which makes the
provider's own table the stash SuppressedReminderStore used to be — no local
mirror, no serialization. ReminderRecovery re-posts those rows when the calendar
is switched back on, so recovery doesn't wait for the next unrelated broadcast.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:33:58 +02:00
ef48717e2c 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>
2026-07-25 13:16:55 +02:00
41593a0e9d fix(calendars): make the Settings toggle the one visibility model (#75)
Reminders never fired for a calendar hidden at system level and nothing hinted
at it: the provider only schedules reminder alarms for Calendars.VISIBLE=1,
while Calendula filtered with its own disabledCalendarIds pref and parsed
isVisibleInSystem without ever using it — two models that could disagree
indefinitely.

Settings → Calendars now writes Calendars.VISIBLE, one calendar per update
(CalendarProvider2 skips its own checkNextAlarm() reschedule for any selection
that isn't _id=), and every display predicate reads isVisibleInSystem. The
drawer's filter sheet stays a purely in-app declutter and still leaves reminders
alone.

With VISIBLE=0 the provider creates no alert rows, so there is nothing left to
suppress: the disabled-calendar gates, SuppressedReminderStore and the re-enable
recovery are gone. A one-shot migration reconciles the retired set with the app's
state winning — enabled in-app and syncing gets shown, disabled gets hidden,
everything else untouched — so the upgrade changes nothing the user sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:44:56 +02:00
4c1bfc052e Merge pull request 'ci(release): retry the Codeberg release create past the tag-settle 500' (!96) from fix/codeberg-release-retry into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Reviewed-on: #96
2026-07-25 07:09:53 +00:00
bba536394f ci(release): retry the Codeberg release create past the tag-settle 500
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 4m23s
The Codeberg mirror step pushes the tag, then immediately POSTs the release
for it — but Codeberg 500s when the release request outruns its indexing of
the just-pushed ref, and with only one attempt that single 500 skipped the
mirror every release (the same POST succeeds seconds later, as a manual retry
confirmed for v2.16.0). Wrap the create/update in a backoff retry loop that
PATCHes in place if a release already exists, so a transient 5xx no longer
loses the mirror. Step stays best-effort (continue-on-error).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 22:22:52 +02:00
53 changed files with 2690 additions and 830 deletions

View File

@@ -412,20 +412,31 @@ jobs:
"prerelease": False, "prerelease": False,
})) }))
PY PY
# Upsert (re-run safe). POST also creates the tag at target_commitish # Create (or update) the release. Codeberg 500s on a POST/GET against a
# if the push mirror hasn't synced it yet. # tag it has only just received — the release request outruns the
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty') # indexing of the ref we pushed a moment ago — so a single attempt kept
if [ -n "$ID" ]; then # failing and skipping the mirror even though the very same call
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \ # succeeds seconds later. Retry with backoff, and PATCH in place if a
# release already exists (re-run safe). A 5xx body still exits curl 0,
# so the loop, not `set -e`, controls the flow.
ID=""
for attempt in 1 2 3 4 5 6; do
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
if [ -n "$EXIST" ]; then
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases/$EXIST"
ID="$EXIST"; break
fi
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases/$ID" -d @cb-payload.json "$API/releases")
else echo "release POST attempt $attempt HTTP $CODE"
curl -s -o cb-response.json -w "release POST HTTP %{http_code}\n" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases"
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true) ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
fi [ -n "$ID" ] && break
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id." >&2; exit 1; fi sleep $((attempt * 10))
done
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
# Attach APK + checksum, replacing any prior asset of the same name. # Attach APK + checksum, replacing any prior asset of the same name.
for A in "$ASSET_APK" "$ASSET_SUM"; do for A in "$ASSET_APK" "$ASSET_SUM"; do

View File

@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Fixed
- Reminders no longer depend on Android telling Calendula when they are due.
Calendula now works out each reminder's time itself and sets its own alarm for
it. On some phones — Samsung's among them — the system's calendar storage never
sends the signal a calendar app is meant to wake up on, and no amount of
battery or notification settings helps: the reminder is simply never announced.
None of that is visible from inside an app that waits to be told, which is why
it took a second pass to find ([#75]).
Reminders also survive things that used to lose them quietly. After a restart
or an app update Calendula re-arms its alarms, and a reminder whose moment
passed while the phone was off still arrives, as long as the event has not
ended yet.
- All-day reminders now arrive at the time you chose in **Settings →
Notifications**, on every occurrence. A yearly birthday could drift an hour
either way depending on daylight saving, and all-day reminders on calendars
from an account fired in the middle of the night instead of in the morning
([#75]).
- Reminders now arrive for every calendar you have switched on. A calendar that
was hidden at system level — switched off in another calendar app, or never
switched on after being added — still showed its events and listed their
reminders in Calendula, but never notified: Android only schedules reminder
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 ([#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.
## [2.16.0] — 2026-07-24 ## [2.16.0] — 2026-07-24
### Added ### Added
@@ -1112,3 +1153,4 @@ automatically, with zero telemetry and no internet permission.
[#42]: https://codeberg.org/jlmakiola/calendula/issues/42 [#42]: https://codeberg.org/jlmakiola/calendula/issues/42
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44 [#44]: https://codeberg.org/jlmakiola/calendula/issues/44
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70 [#70]: https://codeberg.org/jlmakiola/calendula/issues/70
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75

View File

@@ -33,6 +33,14 @@
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" /> <uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!--
A reboot clears every pending alarm, including the one holding the next
reminder. Now that the app schedules that alarm itself (#75) rather than
leaning on the provider's, it has to hear about the reboot to re-arm it —
otherwise reminders simply stop after a restart.
-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage <!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
returns null and the calendar manager's per-account "manage" button can't returns null and the calendar manager's per-account "manage" button can't
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
@@ -270,17 +278,22 @@
</intent-filter> </intent-filter>
</service> </service>
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts <!-- Reminder delivery is the app's own (#75): it plans the alarms from
no notification itself — a calendar app must (v1.4, Etar model). Instances + Reminders instead of waiting for the provider's
Exported: the broadcast arrives from the provider's process. --> EVENT_REMINDER broadcast, which OEM-modified providers demonstrably
retarget or never send. This receiver takes our scan alarm plus
every outside event that invalidates it — boot and package-replace
wipe pending alarms, and a clock or timezone change moves every
reminder relative to the one that is armed.
Exported: the system broadcasts arrive from outside the app. -->
<receiver <receiver
android:name=".data.reminders.EventReminderReceiver" android:name=".data.reminders.ReminderScheduleReceiver"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.EVENT_REMINDER" /> <action android:name="android.intent.action.BOOT_COMPLETED" />
<data <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
android:host="com.android.calendar" <action android:name="android.intent.action.TIME_SET" />
android:scheme="content" /> <action android:name="android.intent.action.TIMEZONE_CHANGED" />
</intent-filter> </intent-filter>
</receiver> </receiver>

View File

@@ -4,9 +4,12 @@ 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.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
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -39,6 +42,43 @@ class CalendulaApp : Application() {
) )
reconcileAutoBackup() reconcileAutoBackup()
reconcileSpecialDates() reconcileSpecialDates()
reconcileCalendarVisibility()
startReminderDelivery()
}
/**
* Bring reminder delivery up with the process (#75). The app plans and arms
* its own reminder alarms now, so launch is one of the moments that has to
* re-check them: a scan re-arms whatever the system dropped, posts anything
* a missed alarm still owes, and starts watching the provider so an edit
* re-plans without waiting for the next pass. The daily worker is the
* backstop for a device that drops the alarm with no reboot to announce it.
*/
private fun startReminderDelivery() {
val deps = EntryPointAccessors.fromApplication(
this, ReminderMaintenanceWorker.Deps::class.java,
)
val scanner = deps.reminderScanner()
scanner.startWatchingProvider()
scanner.scanInBackground()
ReminderMaintenanceScheduler.apply(this)
}
/**
* 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 permission
* leaves the set pending, and `RootScreen` runs it again once the app comes
* up holding it — whichever way it was granted.
*/
private fun reconcileCalendarVisibility() {
val deps = EntryPointAccessors.fromApplication(
this, CalendarVisibilityReconciler.Deps::class.java,
)
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
deps.calendarVisibilityReconciler().run()
}
} }
/** /**

View File

@@ -110,6 +110,32 @@ interface CalendarDataSource {
/** Permanently delete a local calendar the app owns, with all its events. */ /** Permanently delete a local calendar the app owns, with all its events. */
fun deleteCalendar(id: Long) fun deleteCalendar(id: Long)
/**
* Show or hide the calendar device-wide by writing `Calendars.VISIBLE` — the
* app's one visibility model (#75). `VISIBLE` also gates the provider's own
* reminder scheduling, so switching a calendar off here is what actually
* stops its notifications; switching it on is what brings them back.
* Writable by a plain app (one of the three columns the platform documents
* as such) and device-local — no sync adapter pushes it anywhere.
*/
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
@@ -373,6 +399,40 @@ class AndroidCalendarDataSource @Inject constructor(
if (deleted == 0) throw WriteFailedException("delete calendar id=$id") if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
} }
/**
* Addressed by appended id on the plain (non-sync-adapter) Calendars URI,
* one calendar per call. Both parts are load-bearing:
* `CalendarProvider2.updateInTransaction` short-circuits to a raw database
* update unless the selection is `_id=…`, skipping the dirty marking *and*
* the `checkNextAlarm()` reschedule — i.e. an `_id IN (…)` batch would write
* the flag but never re-arm the reminder alarms this write exists to
* trigger. The sync-adapter URI is avoided so the write also applies to
* synced calendars, which is where the bug bites.
*/
override fun setCalendarVisible(id: Long, visible: Boolean) {
val values = ContentValues().apply {
put(CalendarContract.Calendars.VISIBLE, if (visible) 1 else 0)
}
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
values, null, null,
)
if (rows == 0) throw WriteFailedException("set calendar visibility id=$id")
}
override fun isCalendarVisible(id: Long): Boolean? {
if (!hasCalendarPermission()) return null
return resolver.query(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
arrayOf(CalendarContract.Calendars.VISIBLE),
null, null, null,
)?.use { if (it.moveToFirst()) it.getInt(0) != 0 else null }
}
override fun canWriteCalendars(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long { override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR } val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply { val values = ContentValues().apply {

View File

@@ -31,5 +31,10 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
isManaged = isLocal && isManaged = isLocal &&
getString(CalendarProjection.IDX_MANAGED_MARKER) getString(CalendarProjection.IDX_MANAGED_MARKER)
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true, ?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
// A provider that leaves the column NULL is treated as syncing — the
// harmless default, since this flag only ever holds the one-shot
// visibility migration back from switching a calendar on.
syncsEvents = isNull(CalendarProjection.IDX_SYNC_EVENTS) ||
getInt(CalendarProjection.IDX_SYNC_EVENTS) != 0,
) )
} }

View File

@@ -38,6 +38,20 @@ interface CalendarRepository {
/** Permanently delete a local calendar the app owns, with all its events. */ /** Permanently delete a local calendar the app owns, with all its events. */
suspend fun deleteCalendar(id: Long) suspend fun deleteCalendar(id: Long)
/**
* Show or hide [ids] device-wide (`Calendars.VISIBLE`), which is also what
* turns the provider's reminder scheduling for them on or off — see
* [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)
/** /**
* Every event of the writable local calendars, ready to serialise into a * Every event of the writable local calendars, ready to serialise into a
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]). * whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).

View File

@@ -16,11 +16,17 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first 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.map
import kotlinx.coroutines.flow.merge
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,52 +53,140 @@ 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)
}
} }
override fun calendars(): Flow<List<CalendarSource>> = /**
ticks * Re-query signal for everything filtered by visibility: the provider's own
.onStart { emit(Unit) } * notifications, plus every change to the pending switch-off set (an id
.reQuery { dataSource.calendars() } * leaves it as its `VISIBLE` write lands, which changes what is shown).
.flowOn(io) * [calendarsSnapshot] keeps the two in step.
*/
private fun visibilityTicks(): Flow<Unit> = merge(
ticks.onStart { emit(Unit) },
// The current value is already covered by the tick above; only later
// changes re-query (the set is deduped, so an unrelated DataStore write
// doesn't).
prefs.pendingDisabledCalendarIds.drop(1).map {},
)
// Instances are filtered by the app-side hidden disabled calendar sets // A switch-off the app hasn't been allowed to write yet is folded into the
// (M3): an event is dropped whenever the user has hidden *or* disabled its // flag itself, so every consumer — the Settings switch, the filter sheet,
// calendar. Re-runs when the provider ticks *or* either set changes — // the form and import pickers, the widgets — reads one visibility and can't
// toggling a calendar in the filter sheet or the calendar manager updates // disagree with what the user just tapped. The reconciler reads the data
// every view immediately. [calendars] stays unfiltered so those screens can // source directly, because it needs the provider's own answer.
// list and re-enable hidden/disabled calendars. override fun calendars(): Flow<List<CalendarSource>> =
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> = visibilityTicks().reQuery {
combine( val calendars = calendarsSnapshot()
ticks val pendingDisabled = prefs.pendingDisabledCalendarIds.first()
.onStart { emit(Unit) } if (pendingDisabled.isEmpty()) calendars
.reQuery { else calendars.map {
dataSource.instances( if (it.id in pendingDisabled) it.copy(isVisibleInSystem = false) else it
beginMillis = range.start.toEpochMillis(), }
endMillis = range.endInclusive.toEpochMillis(),
)
},
prefs.hiddenCalendarIds,
prefs.disabledCalendarIds,
) { instances, hidden, disabled ->
val excluded = hidden + disabled
if (excluded.isEmpty()) instances
else instances.filterNot { it.calendarId in excluded }
} }
// hidden and disabled both derive from one DataStore, so toggling // Collapse re-emissions that carry an identical list (see
// either makes both re-emit and combine briefly surfaces the same // [instances]).
// list twice — collapse the duplicate so views don't re-render for it.
.distinctUntilChanged() .distinctUntilChanged()
.flowOn(io) .flowOn(io)
// Instances are filtered by the system's per-calendar VISIBLE flag the
// 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(
visibilityTicks().reQuery {
// All three reads in one pass, so a list of instances is never
// filtered against a visibility snapshot from another tick.
QueriedInstances(
instances = dataSource.instances(
beginMillis = range.start.toEpochMillis(),
endMillis = range.endInclusive.toEpochMillis(),
),
switchedOffCalendarIds = invisibleCalendarIds() +
prefs.pendingDisabledCalendarIds.first(),
)
},
prefs.hiddenCalendarIds,
) { queried, hidden ->
val excluded = hidden + queried.switchedOffCalendarIds
if (excluded.isEmpty()) queried.instances
else queried.instances.filterNot { it.calendarId in excluded }
}
// Any DataStore edit re-emits the hidden set even when it is
// unchanged (e.g. writing the last-used calendar), which would
// re-surface an identical list — collapse those so views don't
// re-render for them.
.distinctUntilChanged()
.flowOn(io)
/** One instances query plus the visibility it must be filtered against. */
private data class QueriedInstances(
val instances: List<EventInstance>,
val switchedOffCalendarIds: Set<Long>,
)
/** Calendars switched off at system level — hidden, and never reminded about. */
private suspend fun invisibleCalendarIds(): Set<Long> = calendarsSnapshot()
.filterNot { it.isVisibleInSystem }
.mapTo(mutableSetOf()) { it.id }
private val calendarsLock = Mutex()
private var cachedGeneration = -1L
private var cachedPending: Set<Long>? = null
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.
*
* The pending switch-off set keys the cache alongside the tick. An id leaves
* that set the moment its `VISIBLE` write lands, while the observer that
* would invalidate the snapshot is only dispatched through the main looper
* afterwards — so a snapshot taken while the id was still pending, read
* against the set that no longer holds it, would report the calendar as *on*
* again and re-admit exactly the events being hidden.
*/
private suspend fun calendarsSnapshot(): List<CalendarSource> = calendarsLock.withLock {
val current = generation.get()
val pending = prefs.pendingDisabledCalendarIds.first()
if (current != cachedGeneration || pending != cachedPending || cachedCalendars.isEmpty()) {
cachedCalendars = dataSource.calendars()
cachedGeneration = current
cachedPending = pending
}
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() + prefs.disabledCalendarIds.first() 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 } }
} }
@@ -118,6 +212,22 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun deleteCalendar(id: Long) = override suspend fun deleteCalendar(id: Long) =
withContext(io) { dataSource.deleteCalendar(id) } withContext(io) { dataSource.deleteCalendar(id) }
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
withContext(io) {
if (dataSource.canWriteCalendars()) {
ids.forEach { dataSource.setCalendarVisible(it, visible) }
// Nothing of ours is left waiting for the provider once the
// write lands (and switching one back on retires its entry).
prefs.removePendingDisabledCalendarIds(ids)
} else if (visible) {
prefs.removePendingDisabledCalendarIds(ids)
} else {
// Read-only permission: the switch still works, app-side, and
// the reconciler flushes it if WRITE_CALENDAR ever arrives.
prefs.addPendingDisabledCalendarIds(ids)
}
}
override suspend fun exportEvents(calendarIds: Set<Long>?) = override suspend fun exportEvents(calendarIds: Set<Long>?) =
withContext(io) { dataSource.exportableEvents(calendarIds) } withContext(io) { dataSource.exportableEvents(calendarIds) }

View File

@@ -0,0 +1,133 @@
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 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 whenever the app comes up holding the calendar
* permission — a grant made on Android's own app-settings screen never reaches
* the permission screen's callback. It is a no-op whenever the pending set is
* empty and the notice has been settled, 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 on an *upgraded* install the first run that
* sees one arms the one-time notice explaining why Calendula no longer lists
* their events. A fresh install never had the old behaviour, so it retires that
* notice unshown — every other device ships with something hidden.
*/
@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 {
// A fresh install has no retired model behind it — nothing to
// migrate, and nothing to explain. Settled ahead of the permission
// gate so an update installed before the first grant can't make a
// first run look like an upgrade afterwards.
if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
val pending = prefs.pendingDisabledCalendarIds.first()
val noticeSettled = prefs.visibilityNoticePending.first() != null
// The steady state, and every run after the first: nothing left to
// drain and nothing left to decide, so don't pay for the query.
if (pending.isEmpty() && noticeSettled) return@withContext
val calendars = dataSource.calendars()
settleNoticeOnce(hasSystemHiddenCalendars(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)
}
}
/**
* Settle the one-time notice: [pending] arms it, false retires it unshown.
* Answered once, by whichever run can answer it first; the answer is stored
* either way, so the notice can't resurface later, when the same state would
* no longer be news to the user.
*/
private suspend fun settleNoticeOnce(pending: Boolean) {
if (prefs.visibilityNoticePending.first() != null) return
prefs.setVisibilityNoticePending(pending)
}
/**
* Whether this install has ever run an earlier version. The notice explains
* a change to behaviour the user has already seen, so a first install has
* nothing to announce — and hidden calendars are the *norm* on a fresh
* device (a second account's, "Holidays in …", a subscribed calendar), which
* would otherwise put a changelog dialog in front of a first-run user.
*/
private fun isUpgradeInstall(): Boolean = try {
@Suppress("DEPRECATION")
val info = context.packageManager.getPackageInfo(context.packageName, 0)
info.lastUpdateTime > info.firstInstallTime
} catch (e: PackageManager.NameNotFoundException) {
Log.w(TAG, "Own package info unavailable; treating as a fresh install", e)
false
}
private fun hasPermission(permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
/** Lets non-injectable entry points (the Application) reach the reconciler. */
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun calendarVisibilityReconciler(): CalendarVisibilityReconciler
}
private companion object {
const val TAG = "CalendarVisibility"
}
}

View File

@@ -19,6 +19,7 @@ internal object CalendarProjection {
// uses to recognise its own managed calendars, independent of any // uses to recognise its own managed calendars, independent of any
// stored preference id (which a backup restore / data wipe can lose). // stored preference id (which a backup restore / data wipe can lose).
MANAGED_MARKER_COLUMN, MANAGED_MARKER_COLUMN,
CalendarContract.Calendars.SYNC_EVENTS,
) )
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1 const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
@@ -36,6 +37,7 @@ internal object CalendarProjection {
const val IDX_ACCESS_LEVEL = 6 const val IDX_ACCESS_LEVEL = 6
const val IDX_DESCRIPTION = 7 const val IDX_DESCRIPTION = 7
const val IDX_MANAGED_MARKER = 8 const val IDX_MANAGED_MARKER = 8
const val IDX_SYNC_EVENTS = 9
} }
internal object InstanceProjection { internal object InstanceProjection {

View File

@@ -18,8 +18,8 @@ import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataS
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton import javax.inject.Singleton
@@ -46,9 +46,9 @@ abstract class DataBindModule {
@Binds @Binds
@Singleton @Singleton
abstract fun bindReminderAlertStore( abstract fun bindReminderInstanceSource(
impl: AndroidReminderAlertStore, impl: ProviderReminderInstanceSource,
): ReminderAlertStore ): ReminderInstanceSource
@Binds @Binds
@Singleton @Singleton

View File

@@ -1,18 +1,25 @@
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.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.distinctUntilChanged
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", * App-side calendar preferences. [hiddenCalendarIds] is the drawer's filter
* separate from the system's per-calendar VISIBLE flag. * 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 * Persisted as a comma-separated string of Long ids; non-numeric tokens are
* silently dropped (defensive — see CalendarPrefsTest). * silently dropped (defensive — see CalendarPrefsTest).
@@ -22,46 +29,63 @@ class CalendarPrefs @Inject constructor(
private val store: DataStore<Preferences>, private val store: DataStore<Preferences>,
) { ) {
val hiddenCalendarIds: Flow<Set<Long>> = store.data.map { prefs -> // Both id sets are deduped: the store is shared with SettingsPrefs, so every
prefs[HIDDEN_IDS_KEY].orEmpty() // unrelated write (a settings toggle, the last-used calendar) re-emits an
.split(',') // identical set otherwise — and a change to the pending set now costs a
.mapNotNull { it.trim().toLongOrNull() } // fresh provider read in CalendarRepositoryImpl.
.toSet() val hiddenCalendarIds: Flow<Set<Long>> = store.data
} .map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
.distinctUntilChanged()
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() }
.distinctUntilChanged()
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(",")
}
} }
} }
/** /**
* App-side preference for "calendars the user has disabled in this app" — a * Whether the one-time "visibility follows this device" notice is still
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from * owed. Null until the reconciler has evaluated it (which needs the calendar
* every surface (drawer filter, event-form picker, import picker) and its * permission), false once it has been shown or was never needed.
* events never appear; it stays listed only in Settings → Calendars so it can
* be re-enabled. Stored exactly like the hidden set; never touches the
* system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are unaffected.
*/ */
val disabledCalendarIds: Flow<Set<Long>> = store.data.map { 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 setDisabledCalendarIds(ids: Set<Long>) { suspend fun setVisibilityNoticePending(pending: Boolean) {
store.edit { prefs -> store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
if (ids.isEmpty()) {
prefs.remove(DISABLED_IDS_KEY)
} else {
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
}
}
} }
/** /**
@@ -79,6 +103,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_NOTICE_KEY = booleanPreferencesKey("visibility_notice_pending")
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id") internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
} }
} }
private fun String?.parseIds(): Set<Long> = orEmpty()
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.toSet()
private fun MutablePreferences.writeIds(key: Preferences.Key<String>, ids: Set<Long>) {
if (ids.isEmpty()) remove(key) else set(key, ids.sorted().joinToString(","))
}

View File

@@ -0,0 +1,42 @@
package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* How far reminder delivery has got. One number, and it replaces everything the
* provider's `CalendarAlerts.STATE` used to do for us (#75).
*
* A scan posts the reminders whose moment falls after this watermark and up to
* now, then moves it to now. That single rule gives both halves of what the
* retired path got from the provider: a scan that runs twice cannot post the
* same reminder again, and a scan that runs *late* — after a reboot, an app
* update or a doze window swallowed the alarm — still posts everything the
* missed alarm would have.
*
* Unset means "never scanned". It is deliberately not treated as zero: the first
* scan after an install or an upgrade would otherwise consider every reminder
* since the epoch overdue and bury the user in notifications.
*/
@Singleton
class ReminderStatePrefs @Inject constructor(
private val store: DataStore<Preferences>,
) {
/** The watermark, or `null` before the first scan has ever run. */
suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first()
suspend fun setLastScanMillis(millis: Long) {
store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis }
}
private companion object {
val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis")
}
}

View File

@@ -1,92 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.provider.CalendarContract
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* True when [this] alert belongs to a calendar the user disabled in-app, so its
* reminder must be suppressed (mirroring the event filtering in
* CalendarRepositoryImpl). Alerts whose calendar is unknown (id 0L — e.g. a
* pre-upgrade snooze PendingIntent minted before EXTRA_CALENDAR_ID existed) are
* never treated as disabled. This is the one predicate the disabled-calendar
* gate is built from: [postableAlerts] here and the choke point in
* [ReminderNotifier.post] both use it.
*/
internal fun ReminderAlert.isForDisabledCalendar(disabledCalendarIds: Set<Long>): Boolean =
calendarId != 0L && calendarId in disabledCalendarIds
/**
* The due alerts that should actually surface as notifications: everything
* except alerts whose calendar the user has disabled in-app. The caller still
* marks the full due set fired, so suppressed alerts are not re-broadcast by the
* provider.
*/
internal fun postableAlerts(
due: List<ReminderAlert>,
disabledCalendarIds: Set<Long>,
): List<ReminderAlert> = due.filterNot { it.isForDisabledCalendar(disabledCalendarIds) }
/**
* Becomes the app that turns the calendar provider's reminder alarms into
* visible notifications (the Etar model — the provider broadcasts
* `EVENT_REMINDER` at reminder time but posts nothing itself).
*
* The broadcast's data URI only carries the alarm time, so it is ignored:
* we query every still-scheduled, due `CalendarAlerts` row ourselves, post
* them, and mark them fired. Posting happens before marking — a crash in
* between re-posts silently (same tag) rather than losing the reminder.
*/
@AndroidEntryPoint
class EventReminderReceiver : BroadcastReceiver() {
@Inject lateinit var alertStore: ReminderAlertStore
@Inject lateinit var notifier: ReminderNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs
@Inject lateinit var calendarPrefs: CalendarPrefs
@Inject lateinit var suppressedStore: SuppressedReminderStore
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
val readGranted = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (!readGranted || !notifier.canPost()) return
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
if (settingsPrefs.remindersEnabled.first()) {
val now = System.currentTimeMillis()
val due = alertStore.dueAlerts(now)
val disabled = calendarPrefs.disabledCalendarIds.first()
val postable = postableAlerts(due, disabled)
// Suppress reminders for disabled calendars, but still mark
// every due alert fired so the provider stops re-broadcasting
// the suppressed ones. Stash those suppressed alerts so
// re-enabling their calendar can recover them (they would
// otherwise stay STATE_FIRED forever with no re-scan).
postable.forEach { notifier.post(it) }
alertStore.markFired(due.map { it.alertId }, now)
suppressedStore.stash(due - postable.toSet(), now)
suppressedStore.purgeExpired(now)
}
} finally {
pendingResult.finish()
}
}
}
}

View File

@@ -18,12 +18,14 @@ import javax.inject.Inject
* intents (notification action buttons and our own [ReminderSnoozeScheduler] * intents (notification action buttons and our own [ReminderSnoozeScheduler]
* alarm), so the receiver is not exported. * alarm), so the receiver is not exported.
* *
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is * - **Dismiss** just cancels the notification — the scan's watermark has moved
* already fired, so nothing re-posts it. * past this reminder, so nothing re-posts it.
* - **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 its own
* trigger, outside the ordinary scan.
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class ReminderActionReceiver : BroadcastReceiver() { class ReminderActionReceiver : BroadcastReceiver() {
@@ -73,7 +75,14 @@ class ReminderActionReceiver : BroadcastReceiver() {
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS" const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW" const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
private const val EXTRA_ALERT_ID = "alert_id" /**
* Not handled here — the notification body opens the detail screen
* directly. It only claims a slot in [requestCode] so that intent stays
* distinct from the three this receiver does handle.
*/
const val ACTION_OPEN = "de.jeanlucmakiola.calendula.reminders.OPEN"
private const val EXTRA_ALERT_KEY = "alert_key"
private const val EXTRA_EVENT_ID = "event_id" private const val EXTRA_EVENT_ID = "event_id"
private const val EXTRA_CALENDAR_ID = "calendar_id" private const val EXTRA_CALENDAR_ID = "calendar_id"
private const val EXTRA_BEGIN = "begin" private const val EXTRA_BEGIN = "begin"
@@ -86,7 +95,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
fun intent(context: Context, action: String, alert: ReminderAlert): Intent = fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
Intent(context, ReminderActionReceiver::class.java).apply { Intent(context, ReminderActionReceiver::class.java).apply {
this.action = action this.action = action
putExtra(EXTRA_ALERT_ID, alert.alertId) putExtra(EXTRA_ALERT_KEY, alert.key)
putExtra(EXTRA_EVENT_ID, alert.eventId) putExtra(EXTRA_EVENT_ID, alert.eventId)
putExtra(EXTRA_CALENDAR_ID, alert.calendarId) putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
putExtra(EXTRA_BEGIN, alert.beginMillis) putExtra(EXTRA_BEGIN, alert.beginMillis)
@@ -97,23 +106,29 @@ class ReminderActionReceiver : BroadcastReceiver() {
} }
/** /**
* A stable request code per (alert, action) so the three PendingIntents * A stable request code per (alert, action) so one notification's
* of one notification stay distinct and don't clobber each other. * PendingIntents stay distinct and don't clobber each other.
*
* The key is a hash now rather than a small row id, so the shift is what
* keeps the action slot intact; the top three bits it drops only make two
* *different* reminders collide, which the intent extras then separate
* (PendingIntents compare their intents too, not just the request code).
*/ */
fun requestCode(alert: ReminderAlert, action: String): Int { fun requestCode(alert: ReminderAlert, action: String): Int {
val actionOffset = when (action) { val actionOffset = when (action) {
ACTION_SNOOZE -> 1 ACTION_SNOOZE -> 1
ACTION_DISMISS -> 2 ACTION_DISMISS -> 2
ACTION_SHOW -> 3 ACTION_SHOW -> 3
ACTION_OPEN -> 4
else -> 0 else -> 0
} }
return alert.alertId.toInt() * 8 + actionOffset return (alert.key.toInt() shl 3) + actionOffset
} }
private fun alertFrom(intent: Intent): ReminderAlert? { private fun alertFrom(intent: Intent): ReminderAlert? {
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
return ReminderAlert( return ReminderAlert(
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L), key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L), eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L), calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L), beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),

View File

@@ -0,0 +1,71 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* True on API < 31 (no restriction), and on 31+ when the exact-alarm capability
* is held — auto-granted via `USE_EXACT_ALARM` on API 33+ (Calendula is a
* calendar app), user-revocable on 3132.
*/
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
/**
* Holds the app's own wake-up for the next reminder — the half of delivery that
* used to be the provider's (#75).
*
* Exactly **one** alarm exists at a time, for the earliest reminder still ahead.
* Every firing re-scans and re-arms, so a reminder added, moved or deleted in
* between is picked up on the next pass instead of needing an alarm per reminder
* to be kept in sync with the provider's tables.
*
* A reminder that lands late is a broken reminder, hence an *exact* alarm; the
* inexact allow-while-idle fallback only applies where the OS withholds the
* capability (API 3132 with the user's permission revoked).
*/
@Singleton
class ReminderAlarmScheduler @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun scheduleScan(triggerAtMillis: Long) {
val alarmManager = context.getSystemService<AlarmManager>() ?: return
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)
if (alarmManager.canScheduleExactCompat()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
}
}
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
fun cancelScan() {
val alarmManager = context.getSystemService<AlarmManager>() ?: return
alarmManager.cancel(scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
}
private fun scanPendingIntent(flags: Int): PendingIntent = PendingIntent.getBroadcast(
context,
SCAN_REQUEST_CODE,
Intent(context, ReminderScheduleReceiver::class.java)
.setAction(ReminderScheduleReceiver.ACTION_SCAN),
flags or PendingIntent.FLAG_IMMUTABLE,
)
private companion object {
// Fixed: there is only ever one scan alarm, and re-arming must replace it.
const val SCAN_REQUEST_CODE = 0x5CA1
}
}

View File

@@ -0,0 +1,37 @@
package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
/**
* One reminder as the notification layer needs it: what to show, and the stable
* [key] that identifies it across a reboot, a re-scan and a reinstall.
*
* [key] used to be the `CalendarAlerts` row id. It is now derived from the
* reminder itself (see [PlannedReminder.key]) because there is no row any more —
* in-house delivery reads `Instances` and `Reminders` and owns the alarm (#75).
* Everything downstream only ever needed it to be stable and unique, which it
* still is: it keys the notification tag, so a reminder posted twice replaces
* itself instead of stacking, and it keys the snooze/dismiss `PendingIntent`s.
*/
data class ReminderAlert(
val key: Long,
val eventId: Long,
val calendarId: Long,
val beginMillis: Long,
val endMillis: Long,
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
val title: String,
val location: String?,
val isAllDay: Boolean,
)
fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert(
key = key,
eventId = instance.eventId,
calendarId = instance.calendarId,
beginMillis = instance.beginMillis,
endMillis = instance.endMillis,
title = instance.title,
location = instance.location,
isAllDay = instance.isAllDay,
)

View File

@@ -1,115 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* One due row of the provider's `CalendarAlerts` table (a join with Events).
* Stays in the data layer: alerts feed the notification path only and never
* reach a screen, so there is no domain model for them.
*/
data class ReminderAlert(
val alertId: Long,
val eventId: Long,
val calendarId: Long,
val beginMillis: Long,
val endMillis: Long,
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
val title: String,
val location: String?,
val isAllDay: Boolean,
)
/**
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
* without a ContentResolver. The provider creates these rows itself — only
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
* email reminders never show up here.
*/
interface ReminderAlertStore {
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
/**
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
* which the user may have declined — then re-broadcasts silently replace
* the already-posted notifications instead (same tag, alert-once).
*/
fun markFired(alertIds: List<Long>, nowMillis: Long)
}
@Singleton
class AndroidReminderAlertStore @Inject constructor(
@ApplicationContext private val context: Context,
) : ReminderAlertStore {
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
CalendarContract.CalendarAlerts.CONTENT_URI,
PROJECTION,
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
arrayOf(
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
nowMillis.toString(),
),
CalendarContract.CalendarAlerts.BEGIN + " ASC",
)?.use { c ->
buildList {
while (c.moveToNext()) {
add(
ReminderAlert(
alertId = c.getLong(0),
eventId = c.getLong(1),
calendarId = c.getLong(2),
beginMillis = c.getLong(3),
endMillis = c.getLong(4),
title = c.getString(5).orEmpty(),
location = c.getString(6)?.takeIf { it.isNotBlank() },
isAllDay = c.getInt(7) == 1,
),
)
}
}
} ?: emptyList()
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
if (alertIds.isEmpty()) return
val values = ContentValues().apply {
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
}
try {
context.contentResolver.update(
CalendarContract.CalendarAlerts.CONTENT_URI,
values,
CalendarContract.CalendarAlerts._ID +
" IN (" + alertIds.joinToString(",") + ")",
null,
)
} catch (e: SecurityException) {
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
}
}
private companion object {
const val TAG = "ReminderAlertStore"
val PROJECTION = arrayOf(
CalendarContract.CalendarAlerts._ID,
CalendarContract.CalendarAlerts.EVENT_ID,
CalendarContract.CalendarAlerts.CALENDAR_ID,
CalendarContract.CalendarAlerts.BEGIN,
CalendarContract.CalendarAlerts.END,
CalendarContract.CalendarAlerts.TITLE,
CalendarContract.CalendarAlerts.EVENT_LOCATION,
CalendarContract.CalendarAlerts.ALL_DAY,
)
}
}

View File

@@ -0,0 +1,128 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.Context
import android.content.ContentUris
import android.provider.CalendarContract
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
import javax.inject.Inject
import javax.inject.Singleton
/**
* The read side of in-house reminder delivery: occurrences and the reminder
* offsets hanging off them, straight out of the provider's own tables.
*
* Deliberately *not* `CalendarAlerts`. That table is the provider's own working
* copy of this same information, and the whole point of #75's second round is
* that we can no longer assume it gets written. `Instances` and `Reminders` are
* plain data the app already reads everywhere else.
*
* An interface so [ReminderScanner] can be exercised on the JVM without a
* ContentResolver, in the shape the rest of `data/calendar` uses.
*/
interface ReminderInstanceSource {
/** Occurrences overlapping `[fromMillis, toMillis]`, of switched-on calendars. */
fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance>
/** `METHOD_ALERT` reminder offsets per event id, for the given events. */
fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>>
/**
* The largest `METHOD_ALERT` offset anywhere in the table, so the query
* window can be stretched to cover it and a long-lead reminder is planned
* before it comes due rather than firing late.
*/
fun longestReminderMinutes(): Int
}
@Singleton
class ProviderReminderInstanceSource @Inject constructor(
@ApplicationContext private val context: Context,
) : ReminderInstanceSource {
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, fromMillis)
ContentUris.appendId(this, toMillis)
}.build()
// `visible` is the calendar's system flag, which the app's one visibility
// model writes (#75) — a switched-off calendar must not plan reminders.
// The status clause mirrors CalendarDataSource.instances: a cancelled
// single occurrence of a series is a real row, and NULL means "normal",
// so a bare `!= CANCELED` would drop every ordinary event.
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
return context.contentResolver.query(
uri, OCCURRENCE_PROJECTION, selection, null, null,
)?.use { c ->
buildList {
while (c.moveToNext()) {
add(
ReminderEventInstance(
eventId = c.getLong(0),
calendarId = c.getLong(1),
beginMillis = c.getLong(2),
endMillis = if (c.isNull(3)) 0L else c.getLong(3),
title = c.getString(4).orEmpty(),
location = c.getString(5)?.takeIf { it.isNotBlank() },
isAllDay = c.getInt(6) == 1,
),
)
}
}
} ?: emptyList()
}
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> {
if (eventIds.isEmpty()) return emptyMap()
val out = mutableMapOf<Long, MutableList<Int>>()
// Batched because the ids go into the selection literally; an unbounded
// `IN (...)` on a busy calendar would grow the SQL past what SQLite takes.
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
context.contentResolver.query(
CalendarContract.Reminders.CONTENT_URI,
REMINDER_PROJECTION,
"${CalendarContract.Reminders.METHOD} = " +
"${CalendarContract.Reminders.METHOD_ALERT} AND " +
"${CalendarContract.Reminders.EVENT_ID} IN (${batch.joinToString(",")})",
null,
null,
)?.use { c ->
while (c.moveToNext()) {
out.getOrPut(c.getLong(0)) { mutableListOf() } += c.getInt(1)
}
}
}
return out
}
override fun longestReminderMinutes(): Int = context.contentResolver.query(
CalendarContract.Reminders.CONTENT_URI,
arrayOf(CalendarContract.Reminders.MINUTES),
"${CalendarContract.Reminders.METHOD} = ${CalendarContract.Reminders.METHOD_ALERT}",
null,
// One row is enough: the provider passes the sort order to SQLite.
"${CalendarContract.Reminders.MINUTES} DESC",
)?.use { c -> if (c.moveToFirst()) c.getInt(0) else 0 } ?: 0
private companion object {
const val EVENT_ID_BATCH = 50
val OCCURRENCE_PROJECTION = arrayOf(
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.CALENDAR_ID,
CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END,
CalendarContract.Instances.TITLE,
CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.ALL_DAY,
)
val REMINDER_PROJECTION = arrayOf(
CalendarContract.Reminders.EVENT_ID,
CalendarContract.Reminders.MINUTES,
)
}
}

View File

@@ -0,0 +1,67 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import java.util.concurrent.TimeUnit
/**
* The backstop under the alarm: a daily scan that runs whether or not the alarm
* survived.
*
* The scan alarm re-arms itself at most a day out, so in the steady state this
* finds nothing to do. It exists for the case the whole feature is about — a
* device that quietly drops the alarm without a reboot to announce it. The
* scheduling half of reminder delivery must not have a single point of failure,
* which is exactly what the provider's broadcast turned out to be.
*/
object ReminderMaintenanceScheduler {
private const val WORK_NAME = "reminder-scan-maintenance"
/** Enqueue the daily backstop; idempotent, so every launch may call it. */
fun apply(context: Context) {
val request = PeriodicWorkRequestBuilder<ReminderMaintenanceWorker>(1, TimeUnit.DAYS)
// The launch scan covers now; let the first periodic run wait.
.setInitialDelay(1, TimeUnit.DAYS)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
}
}
class ReminderMaintenanceWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun reminderScanner(): ReminderScanner
}
override suspend fun doWork(): Result = try {
EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
.reminderScanner()
.scan()
Result.success()
} catch (e: Exception) {
// The scan swallows its own failures; anything reaching here is the
// entry point itself, which a retry will not mend. Never fail the chain.
Log.w(TAG, "Reminder maintenance scan failed", e)
Result.success()
}
private companion object {
const val TAG = "ReminderMaintenance"
}
}

View File

@@ -14,6 +14,7 @@ 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.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
@@ -28,17 +29,20 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Posts one notification per due reminder alert on a dedicated channel. * Posts one notification per due reminder on a dedicated channel. Tapping opens
* Tapping opens the event's detail screen; the tag is the alert id, so a * the event's detail screen.
* re-broadcast of an alert we couldn't mark fired replaces its notification *
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of * The tag is the reminder's stable key, so a scan that posts the same reminder
* duplicating it. * again — a catch-up pass overlapping the alarm that already fired — replaces
* its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce])
* instead of stacking a second one.
*/ */
@Singleton @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 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. */
@@ -49,12 +53,25 @@ class ReminderNotifier @Inject constructor(
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled() return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
} }
suspend fun post(alert: ReminderAlert) { /**
// The single choke point for the disabled-calendar gate: it covers both * The single choke point for "this calendar is switched off". The scan
// the provider broadcast (EventReminderReceiver) and a snoozed re-show * already filters on `Calendars.VISIBLE`, but two paths reach [post] around
// (ReminderActionReceiver), so a calendar disabled after a snooze no * it: a snooze re-shown from its own alarm, armed before the calendar was
// longer notifies — without either receiver duplicating the check. * switched off, and a read-only install whose switch lives app-side
if (alert.isForDisabledCalendar(calendarPrefs.disabledCalendarIds.first())) return * ([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
/**
* Post [alert], unless its calendar is switched off. Returns whether the
* notification was put up, which the snooze re-show path uses to tell a
* silenced reminder from a delivered one.
*/
suspend fun post(alert: ReminderAlert): Boolean {
if (isSilenced(alert.calendarId)) return false
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()
@@ -101,16 +118,18 @@ class ReminderNotifier @Inject constructor(
.build() .build()
try { try {
NotificationManagerCompat.from(context) NotificationManagerCompat.from(context)
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification) .notify(alert.key.toString(), NOTIFICATION_ID, notification)
} catch (e: SecurityException) { } catch (e: SecurityException) {
// POST_NOTIFICATIONS was revoked between canPost() and here. // POST_NOTIFICATIONS was revoked between canPost() and here.
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e) Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
} }
// Handled either way: re-running it would hit the same revoked permission.
return true
} }
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */ /** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
fun cancel(alert: ReminderAlert) { fun cancel(alert: ReminderAlert) {
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID) NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID)
} }
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent = private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
@@ -123,7 +142,11 @@ class ReminderNotifier @Inject constructor(
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity( private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
context, context,
/* requestCode = */ alert.alertId.toInt(), // Shares the per-(alert, action) request-code scheme with the buttons, so
// the key's wider value range can't collide one notification's intents.
/* requestCode = */ ReminderActionReceiver.requestCode(
alert, ReminderActionReceiver.ACTION_OPEN,
),
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis), MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )

View File

@@ -0,0 +1,170 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
/**
* One pass of in-house reminder delivery: read what is planned, post what has
* come due, and arm the next wake-up.
*
* This is the whole loop. Every trigger — the alarm firing, boot, a clock or
* timezone change, an edit landing in the provider, the app starting, the daily
* safety net — runs the same [scan], so there is a single path to reason about
* and no ordering between triggers to get wrong. Re-running it is always safe:
* the watermark in [ReminderStatePrefs] decides what is owed, not the trigger.
*/
@Singleton
class ReminderScanner @Inject constructor(
@ApplicationContext private val context: Context,
private val source: ReminderInstanceSource,
private val calendarDataSource: CalendarDataSource,
private val notifier: ReminderNotifier,
private val alarms: ReminderAlarmScheduler,
private val state: ReminderStatePrefs,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: kotlinx.coroutines.CoroutineDispatcher,
) {
// Triggers overlap freely (an alarm during a burst of edits); serialize so
// two passes can't both read the same watermark and post the same reminder.
private val scanLock = Mutex()
private val scope = CoroutineScope(SupervisorJob() + io)
private val providerChanges = MutableSharedFlow<Unit>(
replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private var watching = false
suspend fun scan() = withContext(io) {
scanLock.withLock {
try {
runScan()
} catch (e: SecurityException) {
// The calendar permission was revoked mid-flight. Nothing to
// re-arm and nothing to recover from — the next grant re-scans.
Log.w(TAG, "Reminder scan lacks the calendar permission", e)
} catch (e: Exception) {
Log.w(TAG, "Reminder scan failed", e)
}
}
}
private suspend fun runScan() {
val now = System.currentTimeMillis()
if (!hasReadCalendar()) return
if (!settingsPrefs.remindersEnabled.first()) {
// Reminders off: drop the wake-up, but keep the watermark moving so
// switching them back on doesn't replay everything missed meanwhile.
alarms.cancelScan()
state.setLastScanMillis(now)
return
}
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
// Reach a little into the past as well: an event already under way can
// still owe a reminder (an all-day "at time of event" encodes to a
// negative offset, which fires after begin), and a catch-up pass needs
// to see the occurrences whose moment it missed.
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
val planned = planReminders(
instances = occurrences,
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }),
zone = ZoneId.systemDefault(),
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
)
val schedule = scheduleReminders(
planned = planned,
lastFiredMillis = reminderWatermark(state.lastScanMillis(), now),
nowMillis = now,
horizonMillis = now + MAX_ALARM_INTERVAL_MILLIS,
)
if (notifier.canPost()) {
schedule.due.forEach { notifier.post(it.toAlert()) }
}
// Advance regardless of whether anything could be posted: a user who
// muted notifications is not owed a backlog when they unmute.
state.setLastScanMillis(now)
alarms.scheduleScan(schedule.nextAlarmMillis)
}
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
/**
* Re-scan when the provider changes, so an event saved or deleted in the app
* re-arms the alarm immediately rather than waiting for the next pass.
*
* Debounced: a single save writes the event, its reminders and its
* attendees, and each lands as its own notification. Only useful while the
* process is alive — every other trigger covers the rest, which is why
* nothing here needs to survive it.
*/
fun startWatchingProvider() {
if (watching) return
watching = true
providerChanges
.debounce(PROVIDER_CHANGE_DEBOUNCE_MILLIS)
.onEach { scan() }
.launchIn(scope)
calendarDataSource.registerChangeListener { providerChanges.tryEmit(Unit) }
}
/** Fire-and-forget scan for callers that are not in a coroutine already. */
fun scanInBackground() {
scope.launch(start = CoroutineStart.DEFAULT) { scan() }
}
private companion object {
const val TAG = "ReminderScanner"
/**
* How far ahead occurrences are read. Stretched further by the longest
* reminder offset in the table, so this is only the floor.
*/
const val LOOKAHEAD_MILLIS = 7L * 24 * 60 * 60 * 1000
/** How far back to look for occurrences that may still owe a reminder. */
const val PAST_WINDOW_MILLIS = 24L * 60 * 60 * 1000
/**
* Never wait longer than a day for the next pass, even with nothing
* pending: it rolls the lookahead window forward and re-arms an alarm
* the system may have dropped.
*/
const val MAX_ALARM_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
const val PROVIDER_CHANGE_DEBOUNCE_MILLIS = 2_000L
}
}

View File

@@ -0,0 +1,63 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Every reason to re-run a reminder scan that arrives from outside the process.
*
* All of them do the same thing, because [ReminderScanner.scan] is idempotent
* and works out what is owed from its watermark rather than from why it was
* called:
*
* - **our own alarm** ([ACTION_SCAN]) — the ordinary case, a reminder is due;
* - **boot** and **package replaced** — both wipe pending alarms, so the app has
* to re-arm or reminders stop silently, which is the failure #75 is about;
* - **time and timezone changes** — they move every reminder relative to the
* armed alarm, and an all-day reminder's fire hour is recomposed in the
* current zone, so both need a fresh plan.
*
* Exported because the system broadcasts arrive from outside. [ACTION_SCAN] is
* ours and always sent as an explicit intent; another app triggering a scan
* early would only make it re-read the provider and re-arm, which is harmless.
*/
@AndroidEntryPoint
class ReminderScheduleReceiver : BroadcastReceiver() {
@Inject lateinit var scanner: ReminderScanner
override fun onReceive(context: Context, intent: Intent) {
// Every action here does the same thing, but the filter still has to be
// checked: the receiver is exported, and the system broadcasts it takes
// are protected, so an intent arriving with any other action did not
// come from where it claims to.
if (intent.action !in HANDLED_ACTIONS) return
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
scanner.scan()
} finally {
pendingResult.finish()
}
}
}
companion object {
const val ACTION_SCAN = "de.jeanlucmakiola.calendula.reminders.SCAN"
private val HANDLED_ACTIONS = setOf(
ACTION_SCAN,
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
)
}
}

View File

@@ -12,9 +12,12 @@ import javax.inject.Singleton
/** /**
* Schedules a one-off exact alarm that re-shows a snoozed reminder. * Schedules a one-off exact alarm that re-shows a snoozed reminder.
* *
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER` * Separate from [ReminderAlarmScheduler]'s scan alarm, and deliberately so: a
* broadcast (the Etar model), but a snoozed reminder has no provider backing — * snooze is pinned to one reminder at a time the user chose, while the scan
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves. * alarm is a single moving wake-up for whatever comes next. A re-show also has
* to outlive the scan's watermark moving past that reminder, so it carries the
* reminder in its own intent rather than re-deriving it.
*
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall * A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
* back to an inexact allow-while-idle alarm only if the OS withholds the * back to an inexact allow-while-idle alarm only if the OS withholds the
* exact-alarm capability (API 3132 where the user revoked it). * exact-alarm capability (API 3132 where the user revoked it).

View File

@@ -1,130 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
/**
* Still relevant while the event has not ended: a reminder for an event that is
* already over is pointless to re-surface. Falls back to the begin time when the
* end is unknown (0L). Used both to decide what to re-post and to purge the stash.
*/
internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean =
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
/**
* Local stash of reminder alerts that fired while their calendar was disabled
* in-app. [EventReminderReceiver] marks every due alert `STATE_FIRED` regardless
* (so the provider stops re-broadcasting the suppressed ones), which would
* otherwise lose those reminders forever — there is no re-scan. Stashing lets
* [de.jeanlucmakiola.calendula.ui.calendars.CalendarsViewModel] re-post them if
* the user re-enables the calendar before the event is over.
*
* Persisted in the shared preferences DataStore as a set of self-describing
* strings (one per alert); the stash never reaches a screen, so there is no
* domain model. Entries whose event has already ended are dropped on the next
* stash/recover/purge, so the stash only ever holds a handful of pending alerts.
*/
@Singleton
class SuppressedReminderStore @Inject constructor(
private val store: DataStore<Preferences>,
) {
/** Add [alerts] to the stash, replacing any existing entry with the same id. */
suspend fun stash(alerts: List<ReminderAlert>, nowMillis: Long) {
if (alerts.isEmpty()) return
store.edit { prefs ->
val byId = decodeAll(prefs).associateByTo(mutableMapOf()) { it.alertId }
alerts.forEach { byId[it.alertId] = it }
prefs.putStash(byId.values.filter { it.isRelevantAt(nowMillis) })
}
}
/**
* Remove and return the still-relevant stashed alerts belonging to any of
* [calendarIds]; drops expired entries for every calendar in passing.
*/
suspend fun recoverFor(calendarIds: Set<Long>, nowMillis: Long): List<ReminderAlert> {
val recovered = mutableListOf<ReminderAlert>()
store.edit { prefs ->
val kept = decodeAll(prefs).filter { alert ->
when {
!alert.isRelevantAt(nowMillis) -> false // expired: drop
alert.calendarId in calendarIds -> { recovered += alert; false }
else -> true
}
}
prefs.putStash(kept)
}
return recovered
}
/** Drop entries whose event has already ended — cheap opportunistic cleanup. */
suspend fun purgeExpired(nowMillis: Long) {
store.edit { prefs ->
prefs.putStash(decodeAll(prefs).filter { it.isRelevantAt(nowMillis) })
}
}
private fun decodeAll(prefs: Preferences): List<ReminderAlert> =
prefs[KEY].orEmpty().mapNotNull { decodeStashEntry(it) }
private fun MutablePreferences.putStash(alerts: List<ReminderAlert>) {
val encoded = alerts.map { encodeStashEntry(it) }.toSet()
if (encoded.isEmpty()) remove(KEY) else set(KEY, encoded)
}
private companion object {
val KEY = stringSetPreferencesKey("suppressed_reminders")
}
}
// One stash entry as a delimited string. The '|' separator is safe because every
// free-text field is Base64-encoded first (that alphabet never contains '|'), and
// a null location is stored as a distinct sentinel that Base64 also never yields.
private const val FIELD_SEP = "|"
private const val NULL_LOCATION = "-"
internal fun encodeStashEntry(alert: ReminderAlert): String = listOf(
alert.alertId.toString(),
alert.eventId.toString(),
alert.calendarId.toString(),
alert.beginMillis.toString(),
alert.endMillis.toString(),
if (alert.isAllDay) "1" else "0",
alert.title.toBase64(),
alert.location?.toBase64() ?: NULL_LOCATION,
).joinToString(FIELD_SEP)
/** Reverse of [encodeStashEntry]; returns null for a malformed entry (dropped). */
internal fun decodeStashEntry(raw: String): ReminderAlert? {
val parts = raw.split(FIELD_SEP)
if (parts.size != 8) return null
return try {
ReminderAlert(
alertId = parts[0].toLong(),
eventId = parts[1].toLong(),
calendarId = parts[2].toLong(),
beginMillis = parts[3].toLong(),
endMillis = parts[4].toLong(),
title = parts[6].fromBase64(),
location = parts[7].takeIf { it != NULL_LOCATION }?.fromBase64(),
isAllDay = parts[5] == "1",
)
} catch (e: NumberFormatException) {
null
} catch (e: IllegalArgumentException) { // bad Base64
null
}
}
private fun String.toBase64(): String =
Base64.getEncoder().encodeToString(toByteArray(Charsets.UTF_8))
private fun String.fromBase64(): String =
String(Base64.getDecoder().decode(this), Charsets.UTF_8)

View File

@@ -0,0 +1,58 @@
package de.jeanlucmakiola.calendula.domain
/**
* 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 hide: Set<Long> = emptySet(),
val settled: Set<Long> = emptySet(),
) {
val isEmpty: Boolean get() = hide.isEmpty() && settled.isEmpty()
}
/**
* 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.
*
* 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]).
*
* [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>,
pendingDisabledIds: Set<Long>,
): CalendarVisibilityPlan {
val byId = calendars.associateBy { it.id }
val hide = mutableSetOf<Long>()
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(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 }

View File

@@ -8,6 +8,13 @@ data class CalendarSource(
val accountName: String, val accountName: String,
val accountType: String, val accountType: String,
val color: Int, val color: Int,
/**
* The system's per-calendar `Calendars.VISIBLE` flag — the single visibility
* model: it decides both what Calendula shows and whether the provider
* schedules this calendar's reminder alarms at all (#75). Settings →
* Calendars writes it; the drawer's filter sheet is a separate, purely
* in-app declutter that leaves reminders alone.
*/
val isVisibleInSystem: Boolean, val isVisibleInSystem: Boolean,
/** /**
* Whether events in this calendar can be created/edited/deleted * Whether events in this calendar can be created/edited/deleted
@@ -34,6 +41,15 @@ data class CalendarSource(
* even after a backup restore clears the app's stored ids. * even after a backup restore clears the app's stored ids.
*/ */
val isManaged: Boolean = false, val isManaged: Boolean = false,
/**
* Whether the provider keeps this calendar's events on the device
* (`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,
) )
data class EventInstance( data class EventInstance(

View File

@@ -0,0 +1,219 @@
package de.jeanlucmakiola.calendula.domain.reminders
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
/**
* Works out *when* each reminder has to fire and *which* ones are due, with no
* provider and no clock of its own — the whole decision layer of in-house
* reminder delivery (#75).
*
* Calendula used to leave both halves to the calendar provider: it scheduled the
* alarms, wrote the `CalendarAlerts` rows, and broadcast `EVENT_REMINDER` at the
* right moment. That chain is intact on stock Android but demonstrably not on
* every device — AOSP's own unbundled calendar carries three separate
* workarounds for OEMs that retarget the broadcast, or that only write the alert
* row at alert time. An app that can only *react* to that broadcast has no way
* to notice it never came.
*
* So the offsets in `CalendarContract.Reminders` are now read as data and turned
* into alarms we own. Everything here is pure: instances and reminder offsets in,
* fire instants out.
*/
/** An occurrence that reminders can hang off, flattened out of `Instances`. */
data class ReminderEventInstance(
val eventId: Long,
val calendarId: Long,
val beginMillis: Long,
val endMillis: Long,
val title: String,
val location: String?,
val isAllDay: Boolean,
)
/**
* One occurrence paired with one of its reminder offsets, and the instant that
* pairing has to fire at.
*/
data class PlannedReminder(
val instance: ReminderEventInstance,
val minutes: Int,
val alarmMillis: Long,
) {
/**
* Stable identity of this reminder, derived from what defines it rather
* than from a provider row id (there is none any more). It keys the
* notification tag and the snooze/dismiss `PendingIntent`s, so it has to
* survive a reboot, a re-scan and a reinstall — the same reminder must land
* on the same notification instead of stacking a second one.
*/
val key: Long = key(instance.eventId, instance.beginMillis, minutes)
private companion object {
fun key(eventId: Long, beginMillis: Long, minutes: Int): Long {
var h = eventId * 1_000_003L
h = (h xor beginMillis) * 31L
return h + minutes
}
}
}
/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */
data class ReminderSchedule(
val due: List<PlannedReminder>,
val nextAlarmMillis: Long,
)
private const val MILLIS_PER_MINUTE = 60_000L
private const val MINUTES_PER_DAY = 1_440
/**
* Pair every instance with each of its event's reminder offsets.
*
* A **timed** occurrence is trivial: `begin` is an absolute instant, so
* `begin minutes` is exact by construction, in any timezone, across any DST
* boundary.
*
* An **all-day** occurrence is not, and taking the offset at face value is what
* makes reminders land at the wrong hour. Its `begin` is UTC midnight, and the
* stored offset is not a plain lead time — `AllDayReminderEncoding` folds the
* wanted wall-clock hour into it, sampled against *one* date's UTC offset. Fire
* at `begin minutes` and every occurrence in a different DST phase than the one
* that was sampled drifts by the offset delta, an hour early in one direction and
* an hour late in the other. Rows written by other apps carry no wall-clock at
* all — a conventional `1440` fires at UTC midnight, which is 01:00 or 02:00
* local in Berlin and the wrong day west of UTC.
*
* So the offset is only read for *which day* it means, via [allDayLeadDays], and
* the hour comes from [allDayTimeMinutes] — the one global "show all-day
* reminders at" setting — recomposed against each occurrence's own date in
* [zone]. 09:00 Berlin is then 09:00 Berlin on every occurrence, whatever the
* offset was when the row was written.
*
* [minutesByEvent] may hold duplicate offsets (two identical reminder rows on one
* event); they collapse, because they would otherwise fight over one notification.
*/
fun planReminders(
instances: List<ReminderEventInstance>,
minutesByEvent: Map<Long, List<Int>>,
zone: ZoneId,
allDayTimeMinutes: Int,
): List<PlannedReminder> = instances.flatMap { instance ->
minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes ->
PlannedReminder(
instance = instance,
minutes = minutes,
alarmMillis = if (instance.isAllDay) {
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
} else {
instance.beginMillis - minutes * MILLIS_PER_MINUTE
},
)
}
}
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
private fun allDayDate(beginMillis: Long): LocalDate =
Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
/**
* How many whole days before its occurrence a raw all-day offset means.
*
* A plain multiple of 1440 is read at face value. That covers rows from other
* calendar apps, which carry no encoded hour — and it stays right for our own
* rows that happen to land on a multiple, because those encode a wall-clock hour
* equal to the sampled UTC offset, so the day count is the same either way.
*
* Anything else is one of ours, with an hour folded in: recover the day count the
* way [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes]
* does for display, by asking which local date the encoded instant falls on.
* Keeping the two in step is what makes the notification arrive on the day the
* event screen says it will.
*/
internal fun allDayLeadDays(rawMinutes: Int, beginMillis: Long, zone: ZoneId): Long {
if (rawMinutes % MINUTES_PER_DAY == 0) return (rawMinutes / MINUTES_PER_DAY).toLong()
val encoded = Instant.ofEpochMilli(beginMillis - rawMinutes * MILLIS_PER_MINUTE)
return ChronoUnit.DAYS.between(encoded.atZone(zone).toLocalDate(), allDayDate(beginMillis))
}
private fun allDayAlarmMillis(
beginMillis: Long,
rawMinutes: Int,
zone: ZoneId,
allDayTimeMinutes: Int,
): Long = allDayDate(beginMillis)
.minusDays(allDayLeadDays(rawMinutes, beginMillis, zone))
.atTime(LocalTime.of(allDayTimeMinutes / 60, allDayTimeMinutes % 60))
.atZone(zone)
.toInstant()
.toEpochMilli()
/**
* Split [planned] into what is due now and when to wake up next.
*
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]` — a
* half-open watermark, so a scan triggered twice cannot post the same reminder
* twice, while a scan that runs late still catches everything the missed alarm
* would have posted. That catch-up is the point: an alarm dropped by a reboot,
* an app update or a doze window is recovered by the next scan rather than lost.
*
* A reminder whose event has already ended is dropped rather than posted late —
* see [isStillRelevant].
*
* [nextAlarmMillis] is capped at [horizonMillis] even when nothing is pending, so
* the scan re-runs at least that often and the lookahead window rolls forward.
*/
fun scheduleReminders(
planned: List<PlannedReminder>,
lastFiredMillis: Long,
nowMillis: Long,
horizonMillis: Long,
): ReminderSchedule {
val due = planned
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
.filter { it.instance.isStillRelevant(nowMillis) }
.distinctBy { it.key }
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
val nextPending = planned
.filter { it.alarmMillis > nowMillis }
.minOfOrNull { it.alarmMillis }
return ReminderSchedule(
due = due,
nextAlarmMillis = minOf(nextPending ?: horizonMillis, horizonMillis),
)
}
/**
* Still worth showing while the occurrence has not ended. Falls back to the
* begin time when the end is unknown (0L).
*/
fun ReminderEventInstance.isStillRelevant(nowMillis: Long): Boolean =
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
/**
* The watermark a scan at [nowMillis] should measure against, given what the
* last one recorded.
*
* A first-ever scan ([lastScanMillis] `null`) claims the present, so an install
* or an upgrade onto in-house delivery does not treat every reminder since the
* epoch as overdue and bury the user in notifications. A watermark in the
* *future* — the clock was moved back, or the user travelled across the date
* line — is clamped for the mirror-image reason: left alone it would silence
* every reminder until real time caught up with it.
*/
fun reminderWatermark(lastScanMillis: Long?, nowMillis: Long): Long =
lastScanMillis?.coerceAtMost(nowMillis) ?: nowMillis
/**
* How far ahead instances must be queried for [scheduleReminders] to see every
* reminder in time: the plain lookahead plus the longest offset any reminder row
* carries, so a "two weeks before" reminder is planned before it comes due
* instead of firing late (the limitation Etar's equivalent documents).
*/
fun reminderQueryHorizon(lookaheadMillis: Long, maxReminderMinutes: Int): Long =
lookaheadMillis + maxOf(0L, maxReminderMinutes * MILLIS_PER_MINUTE)

View File

@@ -7,6 +7,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -21,6 +22,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 +81,17 @@ 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()
// Runs on entry however the permission was granted — including from
// Android's app-settings screen, which only comes back through the
// ON_RESUME check above. Cheap once there is nothing left to do.
LaunchedEffect(Unit) { visibilityNotice.reconcile() }
if (onboardingDone == true && noticePending) {
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
}
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done -> Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
when (done) { when (done) {
true -> CalendarHost( true -> CalendarHost(

View File

@@ -0,0 +1,76 @@
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.calendar.CalendarVisibilityReconciler
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,
private val reconciler: CalendarVisibilityReconciler,
) : ViewModel() {
/**
* Reconcile whenever the app comes up with the calendar permission held.
* The launch itself is covered by `CalendulaApp`, but a permission granted
* on Android's app-settings screen comes back through `RootScreen`'s
* ON_RESUME and never touches the permission screen's callback — so the
* trigger hangs off "we are showing the app", not off one grant route.
* Settled runs cost two DataStore reads and stop there.
*/
fun reconcile() {
viewModelScope.launch { reconciler.run() }
}
val pending: StateFlow<Boolean> = prefs.visibilityNoticePending
.map { it == true }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
fun dismiss() {
viewModelScope.launch { prefs.setVisibilityNoticePending(false) }
}
}
/** Plain informational dialog — one acknowledgement, nothing to decide. */
@Composable
fun CalendarVisibilityNoticeDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.VisibilityOff, contentDescription = null) },
title = { Text(stringResource(R.string.calendars_visibility_notice_title)) },
text = { Text(stringResource(R.string.calendars_visibility_notice_message)) },
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_ok))
}
},
)
}

View File

@@ -139,7 +139,6 @@ fun CalendarsScreen(
viewModel: CalendarsViewModel = hiltViewModel(), viewModel: CalendarsViewModel = hiltViewModel(),
) { ) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle() val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle() val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
@@ -177,7 +176,6 @@ fun CalendarsScreen(
CalendarsList( CalendarsList(
local = calendars.filter { it.isLocal }, local = calendars.filter { it.isLocal },
synced = calendars.filterNot { it.isLocal }, synced = calendars.filterNot { it.isLocal },
disabledIds = disabledIds,
error = error, error = error,
onConsumeError = viewModel::consumeError, onConsumeError = viewModel::consumeError,
backupResult = backupResult, backupResult = backupResult,
@@ -191,8 +189,8 @@ fun CalendarsScreen(
onBack = onBack, onBack = onBack,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id }, onEdit = { calendar -> editorSession++; editorId = calendar.id },
onSetDisabled = viewModel::setDisabled, onSetVisible = viewModel::setCalendarVisible,
onSetAccountDisabled = viewModel::setAccountDisabled, onSetAccountVisible = viewModel::setAccountVisible,
) )
} }
} }
@@ -201,7 +199,6 @@ fun CalendarsScreen(
private fun CalendarsList( private fun CalendarsList(
local: List<CalendarSource>, local: List<CalendarSource>,
synced: List<CalendarSource>, synced: List<CalendarSource>,
disabledIds: Set<Long>,
error: Boolean, error: Boolean,
onConsumeError: () -> Unit, onConsumeError: () -> Unit,
backupResult: BackupResult?, backupResult: BackupResult?,
@@ -215,8 +212,8 @@ private fun CalendarsList(
onBack: () -> Unit, onBack: () -> Unit,
onAdd: () -> Unit, onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit, onEdit: (CalendarSource) -> Unit,
onSetDisabled: (Long, Boolean) -> Unit, onSetVisible: (Long, Boolean) -> Unit,
onSetAccountDisabled: (Collection<Long>, Boolean) -> Unit, onSetAccountVisible: (Collection<Long>, Boolean) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -281,12 +278,12 @@ 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;
// tapping a calendar row opens its editor. // tapping a calendar row opens its editor.
val localDisabled = local.isNotEmpty() && local.all { it.id in disabledIds } val localDisabled = local.isNotEmpty() && local.none { it.isVisibleInSystem }
CalendarGroup( CalendarGroup(
title = stringResource(R.string.calendars_local_header), title = stringResource(R.string.calendars_local_header),
expanded = localExpanded, expanded = localExpanded,
@@ -298,14 +295,14 @@ private fun CalendarsList(
onManage = onAdd, onManage = onAdd,
onToggleExpand = { localExpanded = !localExpanded }, onToggleExpand = { localExpanded = !localExpanded },
showToggleAll = local.isNotEmpty(), showToggleAll = local.isNotEmpty(),
allEnabled = local.none { it.id in disabledIds }, allEnabled = local.all { it.isVisibleInSystem },
onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) }, onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
) { ) {
if (local.isEmpty()) { if (local.isEmpty()) {
HintText(stringResource(R.string.calendars_local_empty)) HintText(stringResource(R.string.calendars_local_empty))
} else { } else {
local.forEachIndexed { index, calendar -> local.forEachIndexed { index, calendar ->
val disabled = calendar.id in disabledIds val disabled = !calendar.isVisibleInSystem
GroupedRow( GroupedRow(
title = calendar.displayName, title = calendar.displayName,
summary = calendar.description, summary = calendar.description,
@@ -317,7 +314,7 @@ private fun CalendarsList(
EnableSwitch( EnableSwitch(
calendarName = calendar.displayName, calendarName = calendar.displayName,
enabled = !disabled, enabled = !disabled,
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) }, onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
) )
}, },
onClick = { onEdit(calendar) }, onClick = { onEdit(calendar) },
@@ -415,7 +412,7 @@ private fun CalendarsList(
.forEach { (account, cals) -> .forEach { (account, cals) ->
val expanded = account !in collapsedAccounts val expanded = account !in collapsedAccounts
val accountType = cals.first().accountType val accountType = cals.first().accountType
val accountDisabled = cals.all { it.id in disabledIds } val accountDisabled = cals.none { it.isVisibleInSystem }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
CalendarGroup( CalendarGroup(
title = account, title = account,
@@ -436,11 +433,11 @@ private fun CalendarsList(
} }
}, },
showToggleAll = true, showToggleAll = true,
allEnabled = cals.none { it.id in disabledIds }, allEnabled = cals.all { it.isVisibleInSystem },
onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) }, onToggleAll = { enabled -> onSetAccountVisible(cals.map { it.id }, enabled) },
) { ) {
cals.forEachIndexed { index, calendar -> cals.forEachIndexed { index, calendar ->
val disabled = calendar.id in disabledIds val disabled = !calendar.isVisibleInSystem
GroupedRow( GroupedRow(
title = calendar.displayName, title = calendar.displayName,
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle, position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
@@ -451,7 +448,7 @@ private fun CalendarsList(
EnableSwitch( EnableSwitch(
calendarName = calendar.displayName, calendarName = calendar.displayName,
enabled = !disabled, enabled = !disabled,
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) }, onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
) )
}, },
) )
@@ -698,10 +695,12 @@ private fun CalendarEditor(
} }
/** /**
* The per-row enable/disable control. Checked = the calendar is shown in the * The per-row on/off control, writing the system's `Calendars.VISIBLE`: checked
* app; unchecking disables it (events, filters and pickers all drop it) without * = the calendar is shown, unchecked = it drops out of every surface (events,
* touching any provider data. Carries its own content description so the toggle * filters, pickers) and the provider stops scheduling its reminders. The flag is
* is self-describing to screen readers even on a dimmed row. * device-local — nothing is deleted and nothing is synced anywhere. Carries its
* own content description so the toggle is self-describing to screen readers
* even on a dimmed row.
*/ */
@Composable @Composable
private fun EnableSwitch( private fun EnableSwitch(
@@ -709,7 +708,7 @@ private fun EnableSwitch(
enabled: Boolean, enabled: Boolean,
onToggle: (Boolean) -> Unit, onToggle: (Boolean) -> Unit,
) { ) {
val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName) val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
Switch( Switch(
checked = enabled, checked = enabled,
onCheckedChange = onToggle, onCheckedChange = onToggle,

View File

@@ -12,10 +12,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
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.reminders.ReminderNotifier
import de.jeanlucmakiola.calendula.data.reminders.SuppressedReminderStore
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -45,10 +42,7 @@ class CalendarsViewModel @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val repository: CalendarRepository, private val repository: CalendarRepository,
private val icsExporter: IcsExporter, private val icsExporter: IcsExporter,
private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs, private val settingsPrefs: SettingsPrefs,
private val suppressedStore: SuppressedReminderStore,
private val notifier: ReminderNotifier,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -62,20 +56,6 @@ class CalendarsViewModel @Inject constructor(
initialValue = emptyList(), initialValue = emptyList(),
) )
/**
* Calendars the user has disabled in the app. This screen is the only
* surface that lists them, so it both reads the set (to dim the rows) and
* toggles it. Every other surface simply excludes these ids.
*/
val disabledCalendarIds: StateFlow<Set<Long>> =
prefs.disabledCalendarIds
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
/** Automatic-backup settings + last-run status, for the Backup section UI. */ /** Automatic-backup settings + last-run status, for the Backup section UI. */
val autoBackup: StateFlow<AutoBackupUiState> = combine( val autoBackup: StateFlow<AutoBackupUiState> = combine(
settingsPrefs.autoBackupEnabled, settingsPrefs.autoBackupEnabled,
@@ -140,54 +120,27 @@ class CalendarsViewModel @Inject constructor(
} }
/** /**
* Enable or disable a calendar app-side. Disabling removes it from every * Switch a calendar on or off. This is the app's one visibility model: it
* surface but Settings → Calendars (and hides its events) without touching * writes the system's `Calendars.VISIBLE`, so the calendar disappears from
* provider data — purely a reversible Calendula-local view choice. * every surface *and* the provider stops (or resumes) scheduling its
* reminders. Nothing is patched by hand — the provider notifies and the
* observer re-queries.
*
* Nothing has to be re-posted on the way back on: reminder delivery plans
* from `Instances` and `Reminders` on every scan (#75), and the provider
* change this write makes triggers one.
*/ */
fun setDisabled(id: Long, disabled: Boolean) { fun setCalendarVisible(id: Long, visible: Boolean) = write {
viewModelScope.launch { repository.setCalendarsVisible(listOf(id), visible)
val current = prefs.disabledCalendarIds.first()
val next = if (disabled) current + id else current - id
if (next != current) {
prefs.setDisabledCalendarIds(next)
if (!disabled) recoverReminders(setOf(id))
}
}
} }
/** /**
* Enable or disable every calendar of one account in a single write — the * Switch every calendar of one account on or off — the "toggle all"
* "toggle all" affordance on an account header. Done as one set update so the * affordance on an account header. Each row is written on its own, in one
* per-calendar [setDisabled] calls can't race each other. * coroutine so the writes can't race each other.
*/ */
fun setAccountDisabled(ids: Collection<Long>, disabled: Boolean) { fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
viewModelScope.launch { repository.setCalendarsVisible(ids, visible)
val current = prefs.disabledCalendarIds.first()
val next = if (disabled) current + ids else current - ids.toSet()
if (next != current) {
prefs.setDisabledCalendarIds(next)
if (!disabled) recoverReminders(current intersect ids.toSet())
}
}
}
/**
* Re-post the reminders that fired while [reEnabledIds] were disabled and are
* still relevant (event not yet over), then drop them from the stash. Runs
* after the disabled set is written, so the notifier's own disabled gate lets
* them through. Best-effort at re-enable time: it mirrors the receiver gates
* (reminders on + postable), and there is no later re-scan, so alerts left
* unposted because those gates are closed are simply released.
*/
private suspend fun recoverReminders(reEnabledIds: Set<Long>) {
if (reEnabledIds.isEmpty()) return
val recovered = suppressedStore.recoverFor(reEnabledIds, System.currentTimeMillis())
if (recovered.isNotEmpty() &&
settingsPrefs.remindersEnabled.first() &&
notifier.canPost()
) {
recovered.forEach { notifier.post(it) }
}
} }
// --- Automatic backup (issue #8) ------------------------------------ // --- Automatic backup (issue #8) ------------------------------------

View File

@@ -177,18 +177,19 @@ class EventEditViewModel @Inject constructor(
repository.calendars().catch { emit(emptyList()) } repository.calendars().catch { emit(emptyList()) }
/** /**
* Writable calendars — the only valid event targets. Disabled calendars are * Writable calendars — the only valid event targets. Calendars switched off
* excluded, so you can't create into a calendar you've removed from the app; * in Settings → Calendars are excluded, so you can't create into one you've
* a last-used preselect landing on a now-disabled calendar falls back to the * turned off; a last-used preselect landing on a now-off calendar falls back
* first remaining writable one (handled by [resolvedCalendarId] and [state]). * to the first remaining writable one (handled by [resolvedCalendarId] and
* Managed special-dates calendars are excluded too: their events are owned by * [state]). Managed special-dates calendars are excluded too: their events
* the contact sync, which would delete any user event created there. * are owned by the contact sync, which would delete any user event created
* there.
*
* This is the list of *targets*. An event already living in an excluded
* calendar keeps it — [state] adds it back to the picker.
*/ */
private val writableCalendars: Flow<List<CalendarSource>> = combine( private val writableCalendars: Flow<List<CalendarSource>> = allCalendars.map { calendars ->
allCalendars, calendars.filter { it.canModifyContents && it.isVisibleInSystem && !it.isManaged }
prefs.disabledCalendarIds,
) { calendars, disabled ->
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
} }
/** The target calendar id, resolved exactly as the form shows it. */ /** The target calendar id, resolved exactly as the form shows it. */
@@ -234,11 +235,16 @@ class EventEditViewModel @Inject constructor(
// off the calendar's durable marker, not a stored id, so it holds after a // off the calendar's durable marker, not a stored id, so it holds after a
// backup restore too. // backup restore too.
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
// The picker offers writable calendars only; when editing a managed event // The picker offers writable calendars only; the event's own calendar is
// its own (excluded) calendar is added back so the row still names it. // added back whenever it isn't among them — a managed special-dates one,
// or one switched off on this device — so the row keeps naming it instead
// of reading as the "no calendar" error, and saving can leave the event
// where it is. A calendar the app may not write to is still no target.
val ownCalendar = resolvedCalendar?.takeIf { own ->
own.canModifyContents && external.writable.none { it.id == own.id }
}
val pickerCalendars = val pickerCalendars =
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar if (ownCalendar != null) external.writable + ownCalendar else external.writable
else external.writable
// An all-day event is date-anchored, so a zone is meaningless on it — // An all-day event is date-anchored, so a zone is meaningless on it —
// the field is withheld from both lists rather than shown as a no-op. // the field is withheld from both lists rather than shown as a no-op.
val offerableFields = EventFormField.entries.toSet() - val offerableFields = EventFormField.entries.toSet() -

View File

@@ -30,12 +30,11 @@ class FilterViewModel @Inject constructor(
combine( combine(
repository.calendars(), repository.calendars(),
prefs.hiddenCalendarIds, prefs.hiddenCalendarIds,
prefs.disabledCalendarIds, ) { calendars, hidden ->
) { calendars, hidden, disabled -> // Calendars switched off in Settings → Calendars are off device-wide
// Disabled calendars are gone from the app entirely — they don't // and don't belong in the drawer's hide/show list (you can't hide
// belong in the drawer's hide/show list (you can't hide what's // what is already off). They live only in Settings → Calendars.
// already disabled). They live only in Settings → Calendars. val enabled = calendars.filter { it.isVisibleInSystem }
val enabled = calendars.filterNot { it.id in disabled }
if (enabled.isEmpty()) { if (enabled.isEmpty()) {
FilterUiState.Failure(FailureReason.NoCalendarsConfigured) FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
} else { } else {

View File

@@ -7,7 +7,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsImporter import de.jeanlucmakiola.calendula.data.ics.IcsImporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
@@ -57,7 +56,6 @@ sealed interface ImportUiState {
class ImportViewModel @Inject constructor( class ImportViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
private val importer: IcsImporter, private val importer: IcsImporter,
private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -87,16 +85,18 @@ class ImportViewModel @Inject constructor(
warnings = parsed.warnings, warnings = parsed.warnings,
) )
else -> { else -> {
// A disabled calendar is removed from the app, so it can't be // A calendar switched off in Settings → Calendars is off
// an import target — exclude it alongside the read-only ones. // everywhere, so it can't be an import target — exclude it
// Managed special-dates calendars are contact-derived and // alongside the read-only ones. Managed special-dates
// editor-locked, so they're not a valid destination either. // calendars are contact-derived and editor-locked, so
val disabled = prefs.disabledCalendarIds.first() // they're not a valid destination either.
ImportUiState.Many( ImportUiState.Many(
events = parsed.events, events = parsed.events,
warnings = parsed.warnings, warnings = parsed.warnings,
calendars = repository.calendars().first() calendars = repository.calendars().first()
.filter { it.canModifyContents && !it.isManaged && it.id !in disabled }, .filter {
it.canModifyContents && !it.isManaged && it.isVisibleInSystem
},
) )
} }
} }

View File

@@ -13,6 +13,8 @@ class PermissionViewModel @Inject constructor() : ViewModel() {
private val _state = MutableStateFlow<PermissionUiState>(PermissionUiState.Rationale) private val _state = MutableStateFlow<PermissionUiState>(PermissionUiState.Rationale)
val state: StateFlow<PermissionUiState> = _state.asStateFlow() val state: StateFlow<PermissionUiState> = _state.asStateFlow()
// The visibility reconcile a grant owes (#75) hangs off RootScreen showing
// the app instead: it has to cover the grants made outside it too.
fun onGranted() { fun onGranted() {
_state.value = PermissionUiState.Granted _state.value = PermissionUiState.Granted
} }

View File

@@ -74,9 +74,14 @@ class SettingsViewModel @Inject constructor(
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
/** Writable calendars — the only ones that take a per-calendar reminder override. */ /**
* Writable calendars that are switched on — the only ones that take a
* per-calendar reminder override. A calendar switched off in Settings →
* Calendars is `VISIBLE = 0`, so the provider schedules no alarms for it and
* a default reminder configured there could never fire (#75).
*/
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars() private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents } } .map { calendars -> calendars.filter { it.canModifyContents && it.isVisibleInSystem } }
.catch { emit(emptyList()) } .catch { emit(emptyList()) }
val state: StateFlow<SettingsUiState> = val state: StateFlow<SettingsUiState> =

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ class CalendarMapperTest {
visible: Int = 1, visible: Int = 1,
accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER, accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER,
description: String? = null, description: String? = null,
syncEvents: Int? = 1,
): MapColumnReader = MapColumnReader( ): MapColumnReader = MapColumnReader(
CalendarProjection.IDX_ID to id, CalendarProjection.IDX_ID to id,
CalendarProjection.IDX_DISPLAY_NAME to displayName, CalendarProjection.IDX_DISPLAY_NAME to displayName,
@@ -24,6 +25,7 @@ class CalendarMapperTest {
CalendarProjection.IDX_VISIBLE to visible, CalendarProjection.IDX_VISIBLE to visible,
CalendarProjection.IDX_ACCESS_LEVEL to accessLevel, CalendarProjection.IDX_ACCESS_LEVEL to accessLevel,
CalendarProjection.IDX_DESCRIPTION to description, CalendarProjection.IDX_DESCRIPTION to description,
CalendarProjection.IDX_SYNC_EVENTS to syncEvents,
) )
@Test @Test
@@ -49,6 +51,18 @@ class CalendarMapperTest {
) )
} }
@Test
fun `sync_events 0 marks the calendar as not syncing its events`() {
assertThat(reader(syncEvents = 0).toCalendarSource().syncsEvents).isFalse()
}
@Test
fun `a NULL sync_events column is treated as syncing`() {
// The harmless default: it only ever holds the visibility migration back
// from switching a calendar on.
assertThat(reader(syncEvents = null).toCalendarSource().syncsEvents).isTrue()
}
@Test @Test
fun `null displayName falls back to placeholder`() { fun `null displayName falls back to placeholder`() {
val src = reader(displayName = null).toCalendarSource() val src = reader(displayName = null).toCalendarSource()

View File

@@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
@@ -41,8 +42,12 @@ class CalendarRepositoryImplTest {
produceFile = { tempDir.resolve("repo_test_prefs.preferences_pb").toFile() }, produceFile = { tempDir.resolve("repo_test_prefs.preferences_pb").toFile() },
) )
private fun makeCal(id: Long, name: String = "Cal $id"): CalendarSource = private fun makeCal(
CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), true) id: Long,
name: String = "Cal $id",
visible: Boolean = true,
): CalendarSource =
CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), visible)
private fun makeEvent( private fun makeEvent(
id: Long, id: Long,
@@ -171,37 +176,40 @@ class CalendarRepositoryImplTest {
} }
@Test @Test
fun `instances drops events whose calendar the user disabled`(@TempDir tempDir: Path) = runTest { fun `instances drops events whose calendar is hidden at system level`(
val prefs = newPrefs(tempDir) @TempDir tempDir: Path,
prefs.setDisabledCalendarIds(setOf(2L)) ) = runTest {
val fake = FakeCalendarDataSource().apply { val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false))
instancesResult = { _, _ -> instancesResult = { _, _ ->
listOf( listOf(
makeEvent(10L, "Enabled", calendarId = 1L), makeEvent(10L, "Shown", calendarId = 1L),
makeEvent(11L, "Disabled", calendarId = 2L), makeEvent(11L, "Switched off", calendarId = 2L),
) )
} }
} }
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler)) val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L) val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test { repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("Enabled") assertThat(awaitItem().map { it.title }).containsExactly("Shown")
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@Test @Test
fun `instances applies the union of hidden and disabled sets`(@TempDir tempDir: Path) = runTest { fun `instances applies the union of hidden and system-invisible calendars`(
@TempDir tempDir: Path,
) = runTest {
val prefs = newPrefs(tempDir) val prefs = newPrefs(tempDir)
prefs.setHiddenCalendarIds(setOf(2L)) prefs.setHiddenCalendarIds(setOf(2L))
prefs.setDisabledCalendarIds(setOf(3L))
val fake = FakeCalendarDataSource().apply { val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L), makeCal(3L, visible = false))
instancesResult = { _, _ -> instancesResult = { _, _ ->
listOf( listOf(
makeEvent(10L, "Shown", calendarId = 1L), makeEvent(10L, "Shown", calendarId = 1L),
makeEvent(11L, "Hidden", calendarId = 2L), makeEvent(11L, "Hidden", calendarId = 2L),
makeEvent(12L, "Disabled", calendarId = 3L), makeEvent(12L, "Switched off", calendarId = 3L),
) )
} }
} }
@@ -215,9 +223,61 @@ class CalendarRepositoryImplTest {
} }
@Test @Test
fun `instances re-emits when the disabled set changes`(@TempDir tempDir: Path) = runTest { fun `instances re-emit after a calendar is switched off in the provider`(
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L))
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "A", calendarId = 1L),
makeEvent(11L, "B", calendarId = 2L),
)
}
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), 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()
// The write itself is what the provider notifies about; the observer
// tick is what makes the views re-query.
repo.setCalendarsVisible(listOf(2L), false)
fake.tick()
assertThat(awaitItem().map { it.title }).containsExactly("A")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `setCalendarsVisible addresses each calendar on its own`(
@TempDir tempDir: Path,
) = runTest {
// An _id IN (…) batch would skip the provider's own reminder-alarm
// reschedule, so every calendar must be written by appended id.
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L), makeCal(3L))
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.setCalendarsVisible(listOf(1L, 3L), false)
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 prefs = newPrefs(tempDir)
val fake = FakeCalendarDataSource().apply { val fake = FakeCalendarDataSource().apply {
canWrite = false
calendarsResult = listOf(makeCal(1L), makeCal(2L))
instancesResult = { _, _ -> instancesResult = { _, _ ->
listOf( listOf(
makeEvent(10L, "A", calendarId = 1L), makeEvent(10L, "A", calendarId = 1L),
@@ -231,11 +291,177 @@ class CalendarRepositoryImplTest {
repo.instances(range).test { repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder() assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder()
prefs.setDisabledCalendarIds(setOf(2L)) 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.
// (The list has to change: an identical one is collapsed.)
fake.calendarsResult = listOf(makeCal(1L), makeCal(2L), makeCal(3L))
fake.tick()
awaitItem()
assertThat(fake.calendarQueries).isEqualTo(2)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `calendars does not re-emit an unchanged list`(@TempDir tempDir: Path) = runTest {
// The store is shared with SettingsPrefs, so an unrelated write would
// otherwise re-run every view's combine for an identical list.
val prefs = newPrefs(tempDir)
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(makeCal(1L)) }
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
repo.calendars().test {
assertThat(awaitItem().map { it.id }).containsExactly(1L)
prefs.setLastUsedCalendarId(1L)
fake.tick()
expectNoEvents()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `a flushed switch-off never reads as on again before the provider ticks`(
@TempDir tempDir: Path,
) = runTest {
// The reconciler's shape: write VISIBLE = 0 straight to the provider,
// then release the id app-side. The provider's notification only arrives
// afterwards (it is dispatched through the main looper), so the release
// must not be read against the snapshot from before the write — that
// would flash exactly the events being hidden back into every view.
val prefs = newPrefs(tempDir)
prefs.addPendingDisabledCalendarIds(setOf(2L))
val fake = FakeCalendarDataSource().apply {
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)
// Warm the snapshot the way an open view would.
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("A") assertThat(awaitItem().map { it.title }).containsExactly("A")
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
fake.setCalendarVisible(2L, false) // no tick(): the observer hasn't fired yet
prefs.removePendingDisabledCalendarIds(setOf(2L))
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("A")
cancelAndIgnoreRemainingEvents()
}
repo.calendars().test {
assertThat(awaitItem().single { it.id == 2L }.isVisibleInSystem).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `searchEvents drops results from calendars that are off or hidden`(
@TempDir tempDir: Path,
) = runTest {
val prefs = newPrefs(tempDir)
prefs.setHiddenCalendarIds(setOf(3L))
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false), makeCal(3L))
searchResult = {
listOf(
makeEvent(10L, "Shown", calendarId = 1L),
makeEvent(11L, "Switched off", calendarId = 2L),
makeEvent(12L, "Hidden", calendarId = 3L),
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
assertThat(repo.searchEvents("e").map { it.title }).containsExactly("Shown")
} }
@Test @Test

View File

@@ -61,7 +61,14 @@ internal class FakeCalendarDataSource : CalendarDataSource {
private val listeners = mutableListOf<() -> Unit>() private val listeners = mutableListOf<() -> Unit>()
override fun calendars(): List<CalendarSource> = calendarsResult /** How often [calendars] was queried — the repository shares one read per tick. */
var calendarQueries = 0
private set
override fun calendars(): List<CalendarSource> {
calendarQueries++
return calendarsResult
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> = override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
instancesResult(beginMillis, endMillis) instancesResult(beginMillis, endMillis)
override fun searchEvents(query: String): List<EventInstance> = searchResult(query) override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
@@ -95,6 +102,27 @@ internal class FakeCalendarDataSource : CalendarDataSource {
updatedCalendars += UpdatedCalendar(id, displayName, color, description) updatedCalendars += UpdatedCalendar(id, displayName, color, description)
} }
/** (id, visible) pairs passed to [setCalendarVisible], in call order. */
val visibilityWrites = mutableListOf<Pair<Long, Boolean>>()
override fun setCalendarVisible(id: Long, visible: Boolean) {
writeError?.let { throw it }
visibilityWrites += id to visible
// Reflect the write so a follow-up [calendars] read sees it, the way the
// provider would once its notification has re-triggered the query.
calendarsResult = calendarsResult.map {
if (it.id == id) it.copy(isVisibleInSystem = visible) else it
}
}
/** Whether the fake holds `WRITE_CALENDAR`; false models a read-only grant. */
var canWrite: Boolean = true
override fun canWriteCalendars(): Boolean = canWrite
override fun isCalendarVisible(id: Long): Boolean? =
calendarsResult.firstOrNull { it.id == id }?.isVisibleInSystem
override fun deleteCalendar(id: Long) { override fun deleteCalendar(id: Long) {
writeError?.let { throw it } writeError?.let { throw it }
deletedCalendarIds += id deletedCalendarIds += id

View File

@@ -52,32 +52,65 @@ class CalendarPrefsTest {
} }
@Test @Test
fun `disabledCalendarIds defaults to empty when unset`(@TempDir tempDir: Path) = runTest { fun `the pending disabled set reads back what an older version stored`(
val prefs = CalendarPrefs(newDataStore(tempDir)) @TempDir tempDir: Path,
assertThat(prefs.disabledCalendarIds.first()).isEmpty() ) = 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.pendingDisabledCalendarIds.first()).isEqualTo(setOf(2L, 9L))
} }
@Test @Test
fun `setDisabledCalendarIds round-trips through DataStore`(@TempDir tempDir: Path) = runTest { fun `the pending disabled set is empty when nothing was ever stored`(
@TempDir tempDir: Path,
) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir)) val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setDisabledCalendarIds(setOf(1L, 42L, 7L)) assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(1L, 42L, 7L))
} }
@Test @Test
fun `setting empty disabled set clears storage`(@TempDir tempDir: Path) = runTest { fun `pending ids are added and dropped one at a time`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir)) val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setDisabledCalendarIds(setOf(1L)) prefs.addPendingDisabledCalendarIds(listOf(2L, 9L))
prefs.setDisabledCalendarIds(emptySet()) prefs.addPendingDisabledCalendarIds(listOf(4L))
assertThat(prefs.disabledCalendarIds.first()).isEmpty()
prefs.removePendingDisabledCalendarIds(setOf(9L))
assertThat(prefs.pendingDisabledCalendarIds.first()).isEqualTo(setOf(2L, 4L))
} }
@Test @Test
fun `hidden and disabled sets are stored independently`(@TempDir tempDir: Path) = runTest { fun `draining the pending set leaves the hidden set alone`(
@TempDir tempDir: Path,
) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir)) val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setHiddenCalendarIds(setOf(1L)) prefs.setHiddenCalendarIds(setOf(1L))
prefs.setDisabledCalendarIds(setOf(2L)) prefs.addPendingDisabledCalendarIds(setOf(2L))
prefs.removePendingDisabledCalendarIds(setOf(2L))
assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L)) assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L))
assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(2L)) }
@Test
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.visibilityNoticePending.first()).isNull()
prefs.setVisibilityNoticePending(true)
assertThat(prefs.visibilityNoticePending.first()).isTrue()
prefs.setVisibilityNoticePending(false)
assertThat(prefs.visibilityNoticePending.first()).isFalse()
} }
} }

View File

@@ -1,71 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class PostableAlertsTest {
private fun alert(alertId: Long, calendarId: Long) = ReminderAlert(
alertId = alertId,
eventId = alertId * 10,
calendarId = calendarId,
beginMillis = 0L,
endMillis = 0L,
title = "Event $alertId",
location = null,
isAllDay = false,
)
@Test
fun `keeps alerts when no calendar is disabled`() {
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 200))
val postable = postableAlerts(due, disabledCalendarIds = emptySet())
assertThat(postable).isEqualTo(due)
}
@Test
fun `drops alerts for a disabled calendar`() {
val keep = alert(1, calendarId = 100)
val drop = alert(2, calendarId = 200)
val postable = postableAlerts(listOf(keep, drop), disabledCalendarIds = setOf(200))
assertThat(postable).containsExactly(keep)
}
@Test
fun `drops every alert when all their calendars are disabled`() {
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
val postable = postableAlerts(due, disabledCalendarIds = setOf(100))
assertThat(postable).isEmpty()
}
@Test
fun `keeps multiple alerts from the same enabled calendar`() {
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
val postable = postableAlerts(due, disabledCalendarIds = setOf(999))
assertThat(postable).isEqualTo(due)
}
@Test
fun `alert with unknown calendar id 0 is never treated as disabled`() {
// Pre-upgrade snooze PendingIntents carry no calendar id (defaults to 0L).
val preUpgrade = alert(1, calendarId = 0L)
assertThat(preUpgrade.isForDisabledCalendar(disabledCalendarIds = setOf(0L))).isFalse()
assertThat(postableAlerts(listOf(preUpgrade), disabledCalendarIds = setOf(0L)))
.containsExactly(preUpgrade)
}
@Test
fun `isForDisabledCalendar matches only the disabled ids`() {
assertThat(alert(1, calendarId = 200).isForDisabledCalendar(setOf(200))).isTrue()
assertThat(alert(1, calendarId = 100).isForDisabledCalendar(setOf(200))).isFalse()
}
}

View File

@@ -1,129 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class SuppressedReminderStoreTest {
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { tempDir.resolve("test_prefs.preferences_pb").toFile() },
)
private fun alert(
alertId: Long,
calendarId: Long,
endMillis: Long = Long.MAX_VALUE,
title: String = "Event $alertId",
location: String? = null,
) = ReminderAlert(
alertId = alertId,
eventId = alertId * 10,
calendarId = calendarId,
beginMillis = 0L,
endMillis = endMillis,
title = title,
location = location,
isAllDay = false,
)
@Test
fun `encode then decode round-trips every field including delimiters`() {
val original = alert(
alertId = 7,
calendarId = 42,
endMillis = 123_456_789L,
// Free-text with the field separator and other awkward characters.
title = "Lunch | with | Alice",
location = "Café, 3rd floor | room B",
).copy(beginMillis = 100L, isAllDay = true)
val decoded = decodeStashEntry(encodeStashEntry(original))
assertThat(decoded).isEqualTo(original)
}
@Test
fun `decode returns null for a malformed entry`() {
assertThat(decodeStashEntry("not-a-valid-entry")).isNull()
}
@Test
fun `null location round-trips`() {
val original = alert(1, calendarId = 1, location = null)
assertThat(decodeStashEntry(encodeStashEntry(original))).isEqualTo(original)
}
@Test
fun `recoverFor returns and removes only the re-enabled calendars`() = runTest {
val store = SuppressedReminderStore(newDataStore(tempDir))
val keep = alert(1, calendarId = 100)
val recoverA = alert(2, calendarId = 200)
val recoverB = alert(3, calendarId = 200)
store.stash(listOf(keep, recoverA, recoverB), nowMillis = 0L)
val recovered = store.recoverFor(setOf(200L), nowMillis = 0L)
assertThat(recovered).containsExactly(recoverA, recoverB)
// The still-disabled calendar's alert stays stashed; the recovered ones are gone.
assertThat(store.recoverFor(setOf(100L, 200L), nowMillis = 0L)).containsExactly(keep)
}
@Test
fun `stash drops alerts whose event already ended`() = runTest {
val store = SuppressedReminderStore(newDataStore(tempDir))
val past = alert(1, calendarId = 100, endMillis = 500L)
val future = alert(2, calendarId = 100, endMillis = 2_000L)
store.stash(listOf(past, future), nowMillis = 1_000L)
assertThat(store.recoverFor(setOf(100L), nowMillis = 1_000L)).containsExactly(future)
}
@Test
fun `purgeExpired removes only entries past their event end`() = runTest {
val store = SuppressedReminderStore(newDataStore(tempDir))
// Stash both before "now" so neither is dropped on write, then advance time.
store.stash(
listOf(
alert(1, calendarId = 100, endMillis = 500L),
alert(2, calendarId = 100, endMillis = 2_000L),
),
nowMillis = 0L,
)
store.purgeExpired(nowMillis = 1_000L)
assertThat(store.recoverFor(setOf(100L), nowMillis = 0L).map { it.alertId })
.containsExactly(2L)
}
@Test
fun `stash replaces an existing entry with the same alert id`() = runTest {
val store = SuppressedReminderStore(newDataStore(tempDir))
store.stash(listOf(alert(1, calendarId = 100, title = "old")), nowMillis = 0L)
store.stash(listOf(alert(1, calendarId = 100, title = "new")), nowMillis = 0L)
val recovered = store.recoverFor(setOf(100L), nowMillis = 0L)
assertThat(recovered).hasSize(1)
assertThat(recovered.single().title).isEqualTo("new")
}
@Test
fun `isRelevantAt is true up to the event end and false after`() {
val a = alert(1, calendarId = 1, endMillis = 1_000L)
assertThat(a.isRelevantAt(999L)).isTrue()
assertThat(a.isRelevantAt(1_000L)).isTrue()
assertThat(a.isRelevantAt(1_001L)).isFalse()
}
@TempDir
lateinit var tempDir: Path
}

View File

@@ -0,0 +1,124 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* 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 = if (local) "LOCAL" else "com.google",
color = 0,
isVisibleInSystem = visible,
isLocal = local,
syncsEvents = syncsEvents,
)
@Test
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.settled).isEmpty()
}
@Test
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.isEmpty).isTrue()
}
@Test
fun `a not-synced calendar is switched off like any other`() {
val plan = calendarVisibilityPlan(
listOf(cal(1L, visible = true, syncsEvents = false)),
setOf(1L),
)
assertThat(plan.hide).containsExactly(1L)
}
@Test
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 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 splits into writes and settled ids`() {
val plan = calendarVisibilityPlan(
listOf(
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
),
pendingDisabledIds = setOf(3L, 4L, 77L),
)
assertThat(plan.hide).containsExactly(3L)
assertThat(plan.settled).containsExactly(4L, 77L)
}
@Test
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()
}
}

View File

@@ -0,0 +1,465 @@
package de.jeanlucmakiola.calendula.domain.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
/**
* The decision layer of in-house reminder delivery (#75). The watermark rules are
* the load-bearing part: they are what makes a dropped alarm recoverable and a
* double scan harmless, now that no provider row records "already fired".
*/
class ReminderPlanTest {
private val now = 1_700_000_000_000L
private val minute = 60_000L
private val day = 24 * 60 * minute
private fun instance(
eventId: Long,
beginMillis: Long = now + 30 * minute,
endMillis: Long = now + 90 * minute,
calendarId: Long = 7L,
isAllDay: Boolean = false,
) = ReminderEventInstance(
eventId = eventId,
calendarId = calendarId,
beginMillis = beginMillis,
endMillis = endMillis,
title = "Event $eventId",
location = null,
isAllDay = isAllDay,
)
/** [planReminders] with the fixtures' zone and all-day hour filled in. */
private fun plan(
instances: List<ReminderEventInstance>,
minutesByEvent: Map<Long, List<Int>>,
zone: ZoneId = berlin,
allDayTimeMinutes: Int = nineAm,
) = planReminders(instances, minutesByEvent, zone, allDayTimeMinutes)
@Test
fun `a reminder fires its offset before the occurrence begins`() {
val begin = now + 30 * minute
val planned = plan(
instances = listOf(instance(1L, beginMillis = begin)),
minutesByEvent = mapOf(1L to listOf(10)),
)
assertThat(planned.map { it.alarmMillis }).containsExactly(begin - 10 * minute)
}
// --- all-day: the hour the user picked, on every occurrence -------------
private val berlin = ZoneId.of("Europe/Berlin")
private val nineAm = 540
/** UTC midnight of [date] — how the provider stores an all-day occurrence. */
private fun allDayBegin(date: String): Long =
LocalDate.parse(date).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
private fun firedAt(alarmMillis: Long, zone: ZoneId = berlin): ZonedDateTime =
java.time.Instant.ofEpochMilli(alarmMillis).atZone(zone)
/**
* The offset AllDayReminderEncoding would store for "[days] before, at
* [timeOfDayMinutes]" when sampled against [eventDate] — i.e. exactly the row
* the app writes today.
*/
private fun encodedAllDayMinutes(
eventDate: String,
days: Long,
timeOfDayMinutes: Int = nineAm,
zone: ZoneId = berlin,
): Int {
val date = LocalDate.parse(eventDate)
val utcMidnight = date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val fire = date.minusDays(days)
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
.atZone(zone).toInstant().toEpochMilli()
return ((utcMidnight - fire) / minute).toInt()
}
private fun allDayAlarm(eventDate: String, rawMinutes: Int, zone: ZoneId = berlin): Long =
plan(
instances = listOf(
instance(1L, beginMillis = allDayBegin(eventDate), isAllDay = true),
),
minutesByEvent = mapOf(1L to listOf(rawMinutes)),
zone = zone,
allDayTimeMinutes = nineAm,
).single().alarmMillis
@Test
fun `an all-day reminder fires at the hour the setting names`() {
// Winter: Berlin is UTC+1. "1 day before" on the 15th means the 14th, 09:00.
val alarm = allDayAlarm("2026-01-15", encodedAllDayMinutes("2026-01-15", days = 1))
assertThat(firedAt(alarm).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-01-14").atTime(9, 0))
}
@Test
fun `a summer occurrence of a row written in winter is not an hour early`() {
// The drift this replaces: the offset was sampled at UTC+1, the occurrence
// falls at UTC+2, and firing at `begin - minutes` would land at 08:00.
val winterRow = encodedAllDayMinutes("2026-01-15", days = 1)
val alarm = allDayAlarm("2026-07-15", winterRow)
assertThat(firedAt(alarm).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
}
@Test
fun `a winter occurrence of a row written in summer is not an hour late`() {
// The same drift in the other direction: sampled at UTC+2, fires at UTC+1.
val summerRow = encodedAllDayMinutes("2026-07-15", days = 1)
val alarm = allDayAlarm("2026-01-15", summerRow)
assertThat(firedAt(alarm).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-01-14").atTime(9, 0))
}
@Test
fun `every occurrence of a yearly all-day series fires at the same wall clock`() {
// A birthday's offset is sampled once; the series must not walk off it.
val row = encodedAllDayMinutes("2026-07-15", days = 1)
val fired = listOf("2026-07-15", "2027-01-15", "2027-07-15", "2028-01-15")
.map { firedAt(allDayAlarm(it, row)).toLocalTime() }
assertThat(fired.toSet()).containsExactly(LocalTime.of(9, 0))
}
@Test
fun `an all-day reminder on the day itself fires that morning`() {
// "At time of event" on an all-day event encodes to a negative offset.
val sameDay = encodedAllDayMinutes("2026-07-15", days = 0)
assertThat(sameDay).isLessThan(0)
val alarm = allDayAlarm("2026-07-15", sameDay)
assertThat(firedAt(alarm).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-15").atTime(9, 0))
}
@Test
fun `a plain 1440 row from another calendar app means one day before`() {
// Foreign rows carry no encoded hour. Read at face value they would fire
// at UTC midnight — 02:00 local in summer Berlin.
val alarm = allDayAlarm("2026-07-15", rawMinutes = 1_440)
assertThat(firedAt(alarm).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
}
@Test
fun `a plain 1440 row west of UTC still means one day before`() {
// UTC midnight of the 15th is the evening of the 14th in New York, so a
// local-date reading of the offset would put this two days out.
val newYork = ZoneId.of("America/New_York")
val alarm = allDayAlarm("2026-07-15", rawMinutes = 1_440, zone = newYork)
assertThat(firedAt(alarm, newYork).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
}
@Test
fun `an encoded row west of UTC fires at the named hour`() {
val newYork = ZoneId.of("America/New_York")
val row = encodedAllDayMinutes("2026-07-15", days = 1, zone = newYork)
val alarm = allDayAlarm("2026-07-15", row, zone = newYork)
assertThat(firedAt(alarm, newYork).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
}
@Test
fun `a zero-day row fires on the event's own date`() {
assertThat(allDayLeadDays(rawMinutes = 0, beginMillis = allDayBegin("2026-07-15"), zone = berlin))
.isEqualTo(0L)
}
@Test
fun `a timed reminder is exact across a DST boundary`() {
// Nothing to re-anchor: begin is an absolute instant either way.
val begin = LocalDate.parse("2026-03-29").atTime(14, 0)
.atZone(berlin).toInstant().toEpochMilli()
val planned = plan(
instances = listOf(instance(1L, beginMillis = begin, isAllDay = false)),
minutesByEvent = mapOf(1L to listOf(30)),
zone = berlin,
allDayTimeMinutes = nineAm,
)
assertThat(firedAt(planned.single().alarmMillis).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-03-29").atTime(13, 30))
}
@Test
fun `every occurrence of a series gets its own reminder`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now + day),
instance(1L, beginMillis = now + 2 * day),
),
minutesByEvent = mapOf(1L to listOf(15)),
)
assertThat(planned.map { it.alarmMillis })
.containsExactly(now + day - 15 * minute, now + 2 * day - 15 * minute)
assertThat(planned.map { it.key }.toSet()).hasSize(2)
}
@Test
fun `duplicate reminder rows collapse to one`() {
val planned = plan(
instances = listOf(instance(1L)),
minutesByEvent = mapOf(1L to listOf(10, 10)),
)
assertThat(planned).hasSize(1)
}
@Test
fun `an event with no reminders plans nothing`() {
val planned = plan(
instances = listOf(instance(1L)),
minutesByEvent = emptyMap(),
)
assertThat(planned).isEmpty()
}
@Test
fun `the same reminder keeps its key across scans`() {
val plan = {
plan(listOf(instance(1L)), mapOf(1L to listOf(10))).single().key
}
assertThat(plan()).isEqualTo(plan())
}
@Test
fun `occurrences of one series get different keys`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now + day),
instance(1L, beginMillis = now + 2 * day),
),
minutesByEvent = mapOf(1L to listOf(15)),
)
assertThat(planned[0].key).isNotEqualTo(planned[1].key)
}
@Test
fun `two reminders on one occurrence get different keys`() {
val planned = plan(
instances = listOf(instance(1L)),
minutesByEvent = mapOf(1L to listOf(10, 30)),
)
assertThat(planned[0].key).isNotEqualTo(planned[1].key)
}
@Test
fun `a reminder whose moment has passed since the last scan is due`() {
val planned = plan(
instances = listOf(instance(1L, beginMillis = now + 5 * minute)),
minutesByEvent = mapOf(1L to listOf(10)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - 10 * minute, nowMillis = now,
horizonMillis = now + day,
)
assertThat(schedule.due).hasSize(1)
}
@Test
fun `a reminder already covered by the watermark does not fire twice`() {
// The scan runs again (a provider change, a reboot) after the alarm that
// already posted this one. Nothing records "fired" but the watermark.
val planned = plan(
instances = listOf(instance(1L, beginMillis = now + 5 * minute)),
minutesByEvent = mapOf(1L to listOf(10)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - 4 * minute, nowMillis = now,
horizonMillis = now + day,
)
assertThat(schedule.due).isEmpty()
}
@Test
fun `a reminder exactly on the watermark does not fire again`() {
val begin = now + 5 * minute
val planned = plan(
instances = listOf(instance(1L, beginMillis = begin)),
minutesByEvent = mapOf(1L to listOf(10)),
)
val alarm = planned.single().alarmMillis
val schedule = scheduleReminders(
planned, lastFiredMillis = alarm, nowMillis = now, horizonMillis = now + day,
)
assertThat(schedule.due).isEmpty()
}
@Test
fun `a missed alarm is caught up by a much later scan`() {
// The device was off over the reminder; the scan on boot must still post it
// while the event is ahead. This is what the provider path could never do.
val planned = plan(
instances = listOf(instance(1L, beginMillis = now + 5 * minute)),
minutesByEvent = mapOf(1L to listOf(60)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - 5 * day, nowMillis = now,
horizonMillis = now + day,
)
assertThat(schedule.due).hasSize(1)
}
@Test
fun `a reminder for an occurrence that already ended is dropped`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now - 3 * 60 * minute, endMillis = now - 2 * 60 * minute),
),
minutesByEvent = mapOf(1L to listOf(10)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - 5 * day, nowMillis = now,
horizonMillis = now + day,
)
assertThat(schedule.due).isEmpty()
}
@Test
fun `an occurrence with no end falls back to its begin for relevance`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now - minute, endMillis = 0L),
),
minutesByEvent = mapOf(1L to listOf(10)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - day, nowMillis = now, horizonMillis = now + day,
)
assertThat(schedule.due).isEmpty()
}
@Test
fun `due reminders come out in occurrence order`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now + 20 * minute),
instance(2L, beginMillis = now + 5 * minute),
),
minutesByEvent = mapOf(1L to listOf(30), 2L to listOf(30)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - day, nowMillis = now, horizonMillis = now + day,
)
assertThat(schedule.due.map { it.instance.eventId }).containsExactly(2L, 1L).inOrder()
}
@Test
fun `the next wake-up is the earliest reminder still ahead`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now + 20 * minute),
instance(2L, beginMillis = now + 90 * minute),
),
minutesByEvent = mapOf(1L to listOf(5), 2L to listOf(5)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now, nowMillis = now, horizonMillis = now + day,
)
assertThat(schedule.nextAlarmMillis).isEqualTo(now + 15 * minute)
}
@Test
fun `with nothing pending the scan still re-runs at the horizon`() {
val schedule = scheduleReminders(
planned = emptyList(), lastFiredMillis = now, nowMillis = now,
horizonMillis = now + day,
)
assertThat(schedule.nextAlarmMillis).isEqualTo(now + day)
}
@Test
fun `a reminder beyond the horizon waits for the next scan`() {
// Capping keeps the rolling window honest: the far-off reminder is picked
// up by a later scan rather than pinned to an alarm we may never re-check.
val planned = plan(
instances = listOf(instance(1L, beginMillis = now + 30 * day)),
minutesByEvent = mapOf(1L to listOf(5)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now, nowMillis = now, horizonMillis = now + day,
)
assertThat(schedule.nextAlarmMillis).isEqualTo(now + day)
}
@Test
fun `the query horizon stretches past the longest reminder offset`() {
// A "2 weeks before" reminder has to be planned while its event is still
// outside the plain lookahead, or it fires late.
assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = 20_160))
.isEqualTo(7 * day + 14 * day)
}
@Test
fun `a first-ever scan starts from now, not from the epoch`() {
// Otherwise an install — or the upgrade onto in-house delivery — treats
// every reminder ever set as overdue and posts the lot.
assertThat(reminderWatermark(lastScanMillis = null, nowMillis = now)).isEqualTo(now)
}
@Test
fun `an ordinary watermark is used as recorded`() {
assertThat(reminderWatermark(lastScanMillis = now - day, nowMillis = now))
.isEqualTo(now - day)
}
@Test
fun `a watermark left in the future by a clock change is clamped`() {
// Left alone it would silence every reminder until real time caught up.
assertThat(reminderWatermark(lastScanMillis = now + 5 * day, nowMillis = now))
.isEqualTo(now)
}
@Test
fun `a negative longest offset does not shrink the query horizon`() {
assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = -420))
.isEqualTo(7 * day)
}
}

View File

@@ -45,9 +45,9 @@ class EventEditViewModelTest {
private val beginMillis = 1_781_164_800_000L private val beginMillis = 1_781_164_800_000L
private val endMillis = beginMillis + 3_600_000L private val endMillis = beginMillis + 3_600_000L
private fun cal(id: Long): CalendarSource = CalendarSource( private fun cal(id: Long, visible: Boolean = true): CalendarSource = CalendarSource(
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL", id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true, color = 0xFF112233.toInt(), isVisibleInSystem = visible, canModifyContents = true,
) )
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail( private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
@@ -90,6 +90,45 @@ class EventEditViewModelTest {
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */ /** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
private fun CoroutineScope.activate(vm: EventEditViewModel): Job = launch { vm.state.collect {} } private fun CoroutineScope.activate(vm: EventEditViewModel): Job = launch { vm.state.collect {} }
@Test
fun `a calendar switched off in settings is not offered as a target`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L), cal(2L, visible = false))
eventDetailResult = { detail(calendarId = 1L) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
assertThat(vm.state.value?.calendars?.map { it.id }).containsExactly(1L)
job.cancel()
}
@Test
fun `editing an event in a switched-off calendar keeps it in the picker`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
// Otherwise the calendar row renders as the "no calendar" error and any
// pick routes the save through a move the user never asked for.
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L), cal(2L, visible = false))
eventDetailResult = { detail(calendarId = 2L) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
assertThat(vm.state.value?.calendars?.map { it.id }).containsExactly(1L, 2L)
assertThat(vm.state.value?.form?.calendarId).isEqualTo(2L)
job.cancel()
}
@Test @Test
fun `changing the calendar routes the save through a move, not an update`( fun `changing the calendar routes the save through a move, not an update`(
@TempDir tempDir: Path, @TempDir tempDir: Path,

View File

@@ -33,7 +33,7 @@ flowchart TD
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"] Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
DS["CalendarDataSource\n(interface + AndroidCalendarDataSource)"] DS["CalendarDataSource\n(interface + AndroidCalendarDataSource)"]
Prefs["SettingsPrefs / CalendarPrefs\n(DataStore)"] Prefs["SettingsPrefs / CalendarPrefs\n(DataStore)"]
Rem["reminders/\nReminderAlertStore + ReminderNotifier"] Rem["reminders/\nReminderScanner + ReminderNotifier"]
end end
Provider[("CalendarContract\n(system calendar provider)")] Provider[("CalendarContract\n(system calendar provider)")]
@@ -137,29 +137,75 @@ can't fake a conflict.
## Reminder delivery ## Reminder delivery
The provider schedules reminder alarms (for `METHOD_ALERT` rows only) and Calendula plans and fires its own reminders. It reads the offsets in
broadcasts `EVENT_REMINDER` — but posts no notification; a calendar app `Reminders` as data, works out when each occurrence's reminder is due, and holds
must (the Etar model): **one** exact alarm for the earliest one still ahead:
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant P as CalendarProvider participant T as Trigger (alarm / boot / time change / edit / launch / daily worker)
participant R as EventReminderReceiver participant Sc as ReminderScanner
participant S as ReminderAlertStore participant Src as ReminderInstanceSource
participant P as ReminderPlan (pure)
participant N as ReminderNotifier participant N as ReminderNotifier
P->>R: EVENT_REMINDER broadcast (manifest receiver, exported) participant A as ReminderAlarmScheduler
R->>S: dueAlerts(now) — CalendarAlerts: SCHEDULED, alarmTime ≤ now T->>Sc: scan()
S-->>R: due alerts Sc->>Src: occurrences(window) + reminderMinutes(ids)
R->>N: post(alert) — one notification per alert, tag = alert id Src-->>Sc: Instances ⋈ Reminders
R->>S: markFired(ids) — best effort, needs WRITE_CALENDAR Sc->>P: planReminders / scheduleReminders(watermark, now)
P-->>Sc: due + next alarm
Sc->>N: post(alert) — tag = reminder key
Sc->>A: scheduleScan(next)
``` ```
Posting happens before marking: a crash in between re-posts silently (same **Why not the provider's broadcast.** It used to schedule the alarms, write the
tag + `setOnlyAlertOnce`) rather than losing a reminder. Swiped `CalendarAlerts` rows and broadcast `EVENT_REMINDER`, and the app only reacted.
notifications never return because `FIRED` rows are never re-queried. That chain holds on stock Android and demonstrably not everywhere: AOSP's own
Deliberately absent until real devices prove it necessary: own alarm unbundled calendar carries three separate workarounds for OEM providers that
scheduling, `BOOT_COMPLETED`, snooze/dismiss actions, battery-exemption retarget the broadcast or only write the alert row at alert time. A reacting app
prompts. cannot tell "nothing was due" from "the broadcast never came" (#75) — and the
reporter's silent events were in a calendar Calendula created itself, so
`VISIBLE` was never the cause there.
**The watermark replaces `CalendarAlerts.STATE`.** A scan posts the reminders
whose moment falls in `(lastScan, now]`, then moves the mark
(`ReminderStatePrefs`). Half-open, so a scan that runs twice cannot post twice,
while a scan that runs *late* still posts what the missed alarm owed — a reboot,
an app update or a doze window costs nothing. A first-ever scan claims the
present rather than the epoch, and a watermark left in the future by a clock
change is clamped. Every trigger runs the same idempotent `scan()`, so there is
no ordering between them to get wrong; `BOOT_COMPLETED` and `MY_PACKAGE_REPLACED`
matter because both wipe pending alarms.
**All-day reminders fire at the hour the setting names.** The stored offset is
not a plain lead time — `AllDayReminderEncoding` folds a wall-clock hour into it,
sampled against one date's UTC offset — so taking it at face value drifts by the
offset delta across a DST boundary, and rows from other apps carry no hour at
all. The offset is therefore read only for *which day* it means; the hour comes
from the global all-day reminder setting, recomposed against each occurrence's
own date. Timed reminders need none of this: `begin` is an absolute instant.
**One visibility model.** The scan only plans occurrences of calendars with
`Calendars.VISIBLE = 1`, and 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`. 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: a fallback to the provider's `EVENT_REMINDER` broadcast.
Keeping both would double-post wherever the provider works, and Etar's way out —
a latch that disables its own scheduling once a real broadcast arrives — cannot
be copied, because our failure mode includes a broadcast that arrives with no
alert row behind it.
## Testing ## Testing