A review of every path through the OpenTasks/tasks.org ContentProvider,
prompted by edited due times reverting. Four independent defects produced that
one symptom, plus several unrelated ones alongside.
Edits reverting
- The edit form was bound from a LaunchedEffect in the nav host while its
ViewModel survives on the back stack, and bindEdit replaced state wholesale.
MainActivity declares no configChanges, so any Activity recreation (rotation,
theme/font/display-size change, split-screen, unfolding) re-fired the effect
and overwrote in-progress edits with the stored row. Guarded with a `bound`
flag; picker state moved to rememberSaveable so an open picker also survives.
All-day handling
- All-day items are date-only in iCalendar and belong at UTC midnight with a
null tz. The app wrote *local* midnight, so in Berlin an all-day task drifted
back a day on every save cycle, corrupting anything synced. Rendering had the
mirror bug, so the two cancelled out locally and hid each other.
- Toggling the all-day switch flipped the flag but left the timestamp, so an
all-day task toggled off read back as 02:00 — another apparent "time reset".
- New domain/AllDayTime.kt owns the two conventions and the conversion between
them; the picker, the write mapper and the toggle all go through it.
Provider write contract
- DUE and DURATION are mutually exclusive and the provider validates the merged
row, so saving a due date onto a task that carried a duration threw
IllegalArgumentException — the save simply failed. DURATION is now cleared
alongside every time write.
- A recurring task's start/due are read from the instances view, and writing
them back to tasks/<id> re-anchored the whole series. Updates now go through
instances/<id>, where the provider forks an override instead.
- Recurrence is derived from rrule/rdate rather than the is_recurring column:
that column only exists from OpenTasks 1.4.0 (DB 23) and is absent on
tasks.org's bundled provider (DB 22), where it would report every recurring
task as one-off and send its edits to the anchor.
Reminders
- The per-task Reminder field in the edit form was inert: never persisted,
never read back, and REMINDER_WITHOUT_DUE could block a save over a value
that was discarded regardless. Leads are now stored as Alarm property rows
and preferred over the per-list/global setting. Written before the task
update so a recurrence fork copies them onto the override. Note the provider
fires nothing itself — ReminderScheduler still arms the alarm.
- Reminders were keyed by task id over rows read from the instances view, so
.toMap() collapsed a recurring task to one arbitrary occurrence (the query is
unsorted). Now keyed per occurrence, with request codes and intent data to
match. Missed reminders within 6h fire once on boot instead of being dropped.
Robustness
- Four terminal `catch`es killed their upstream on the first provider failure.
SettingsViewModel is collected in setContent above the permission gate for the
Activity's lifetime, so a pre-grant SecurityException left the list picker
empty until the process restarted. Replaced with capped-backoff retry.
- lazyChildren had no catch at all; an exception escaped stateIn past
viewModelScope's SupervisorJob and crashed the process.
- Observer registration is all-or-nothing (the second register throwing leaked
the first), ProviderChangeReceiver validates action and authority and
debounces, and the permission gate re-checks on resume.
Also drops the unused DateTimeField composable and the stale INSTANCES
projection, which omitted the recurrence columns the mapper now depends on.
Bumps floret-kit to pick up the matching all-day formatting fix.
Verified by unit tests (43 app, 15 core-time) and a clean assembleDebug; the
provider interaction itself has not been exercised on a device.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>