Brings #248 (EXDATE moves with the series when its times change) onto the
release branch, alongside the translations and dependency updates that landed on
main.
#245 and #248 both grew EventWriteMapper's EXDATE handling in the same place,
which conflicted. They are independent — #245 added exdateContains for the
detach path, #248 added shiftedExdate/exdateAfter for re-anchoring — so both
sides are kept, and their tests with them.
Import learned to read EXDATE, but the export side never wrote it: an in-app
backup and restore brought back every occurrence the user had deleted. EXDATE
lives on the master row, so the export query's ORIGINAL_ID IS NULL filter never
hid it — it just wasn't in the projection.
An all-day series' exclusions are written VALUE=DATE, since RFC 5545 ties
EXDATE's value type to DTSTART's and a bare day code without it reads as a
malformed DATE-TIME. Sync adapters disagree on whether an all-day exclusion is
yyyyMMdd or a padded midnight stamp, so the time part is dropped on the way out.
DUE was read only when a VTODO had no DTSTART, and never as the end. A task
carrying both imported as a zero-length event instead of the span between them.
It is now resolved after the property sweep, so a DUE ahead of the DTSTART isn't
mistaken for the start either.
A timed VALARM firing after the start is a follow-up alarm and models no lead
time — clamping it to zero invented a reminder at the start that the file never
asked for. Dropped again, as the insert path did before. The clamp stays for
all-day, where it's how the Fossify family writes "on the day at 09:00".
EventRecurrence.parse ends with two global checks, not one: a missing FREQ
throws, and so does UNTIL together with COUNT. Only the first was mirrored, so a
rule with both still threw out of insert — the failure sanitizeRrule exists to
prevent. UNTIL is kept; it bounds the series at a date a stale COUNT can't
outrun.
Also mark a part that carries text but no '=' as a repair. It took the branch
meant for a trailing ';', so the rule was altered while the user was told
nothing had changed.
Review pass over the Fossify import work:
- sanitizeRrule whitelists the parts EventRecurrence actually parses, and
reports whether it dropped anything — normalising case or a stray ";" no
longer warns the user about a faulty repeat rule.
- CATEGORIES only stands in for a calendar name when it is single-valued and
every event agrees; elsewhere it is a tag list and must not pick the target.
- An all-day EXDATE keeps the day it spells out instead of the day its instant
lands on in UTC.
- An imported all-day reminder is sampled where it will fire, not at a
recurrence anchor that Fossify dates to 1970.
- Imported colours match against every published key, not the picker's
curated subset.
Review follow-ups on the import work:
- All-day reminder offsets are `days * 1440 - timeOfDay`, so rounding to the
nearest day lost a day for any producer whose all-day notifications fire after
noon ("1 day before at 18:00" arrives as -PT6H). Round up instead.
- Export decodes an all-day row's raw provider offset back to its whole-day lead
time. It is normally negative, and the writer discards a trigger that fires
after the event, so our own backups came back with no all-day reminder at all.
- A floating EXDATE DATE-TIME is read in the series' zone, not the device's;
otherwise the exclusion lands on an instant no occurrence has.
- Wire the parsed calendar name up: it had no reader, so the CATEGORIES handling
was inert. It now preselects a target calendar of that name.
Found the actual cause. Fossify mirrors Contacts birthdays and
anniversaries with startTS == endTS (MainActivity), so its exporter's
dayCode(endTS + 12h) rounds back to the starting day and writes
DTEND == DTSTART. We read that literally: a yearly series with
DURATION:P0D, which the provider expands into no instances at all. The
import reported success and the events were nowhere — matching the
report exactly ("birthdays, memorials, dates").
An all-day event may no longer end at or before it starts, and an all-day
DURATION is floored at P1D. Verified against the released parser, which
returns days=0.0 for all three fixture events.
Do not generalise this into sniffing PRODID: the same exporter is correct
for UI-created events (endTS anchors at noon of the last day) and for
CalDAV rows, and Fossify's bundled holiday files are conformant while
naming Fossify in their PRODID. Both are kept as fixtures.
Also: existingUids counted DELETED rows, so re-importing after deleting
events skipped everything as duplicates.
Closes#225
importEvents inserted every event in one unguarded loop, so a single row
the provider refused — it throws on a malformed RRULE straight out of
insert — aborted the batch and left the user with "couldn't read this
file" and nothing imported. Each event is now isolated and rejects are
counted into IcsImportSummary.failed and shown. sanitizeRrule drops empty
and malformed rule parts before the write.
Alongside that, several things a foreign file carries that we dropped:
VTODO components (silently lost, now imported as events), EXDATE, the
CATEGORIES calendar name, and colours — X-FOSSIFY-EVENT-COLOR / COLOR /
the category colour plus the X-SMT-* legacy spellings, snapped in Oklab
to the nearest key a palette account publishes since those reject a raw
EVENT_COLOR.
Two correctness fixes on our side: an all-day event may no longer end at
or before it starts (the provider expands a zero-length series into no
instances, so it just disappears), and imported all-day reminders now go
through the same encoding as hand-created ones instead of firing at UTC
midnight. A VALARM trigger pointing after the start is read as a time of
day rather than clamped to zero.
Note for later: their all-day DTEND is RFC-correct — endTS anchors at
noon of the last day and the exporter's +12h rounds it to the following
midnight. Do not "fix" it by sniffing PRODID; the holiday files bundled
in Fossify are conformant and name Fossify in theirs. One is kept as a
fixture.
Refs #225
## What was wrong
"Edit only this event" wrote a modified-occurrence exception unconditionally. That is the right shape for a synced series and the wrong one for everything else: an exception attaches to its parent through `ORIGINAL_SYNC_ID`, so on a series row with no `_sync_id` the link never forms — the insert fails or lands an orphan, the generic catch in `EventEditViewModel.performSave` turns it into a snackbar, and the scope dialog just closes again. To the reporter that read as "nothing happens, ever", with a stray copy of the event the one time the insert did land.
This is the same constraint `deleteOccurrence` has documented since #47, and the same calendars: a local calendar, Calendula's own contact special-date calendars, and — the reporter's case — a Google calendar whose rows the sync adapter has not stamped yet, which is exactly why it "shows as on-device". `deleteOccurrence` got the `_sync_id` guard in 4fea176; `updateOccurrence` never did.
## What changed
`updateOccurrence` now branches on `_sync_id` the way `deleteOccurrence` does.
- **Synced series**: the exception path, untouched. Its write shape is load-bearing and on-device verified (#16, #47).
- **No `_sync_id`**: the occurrence is excluded from the parent via EXDATE and the edited values are inserted as a standalone event on the same calendar — a detached instance, minus the `RECURRENCE-ID` the provider has no way to store here.
Two things shape the write. The parent update reuses `buildOccurrenceExdateValues` unchanged, so it keeps carrying the whole time/recurrence set — an EXDATE-only update is not a recurrence change to the provider and leaves the expanded instances standing (#47's first quirk). And the form's RRULE is stripped before the insert: the exception path gets an inherited rule cleared for free by DTSTART + DURATION, but nothing clears one here, so leaving it would insert a second *series* overlapping the first.
Ordering is chosen for the failure cases. The insert runs first, so a failure there leaves the series completely untouched — the discipline `updateEventFromOccurrence` already follows. If the EXDATE update then fails, the new row is a visible duplicate of an occurrence still in the series, so it is rolled back (best effort) before the failure surfaces. The reverse order could strand an occurrence excluded from its series with nothing standing in for it, turning an edit into a silent delete.
A second detach of the same occurrence is refused rather than silently making a second copy (reachable from a stale detail screen: the EXDATE merge folds the repeat away and the update still reports a changed row). `NoSuchEventException` from a write now maps to the same "no longer exists" state the pre-check already gives.
Smaller, in the same area: a failed save was invisible precisely because this bug was — the failure snackbar gets the long duration instead of a flash, and the catch logs the scope and event id (never the form's content) so a failure leaves something to report.
## What this costs
The detached row has no stored link back to its series — that is the whole reason the path exists — and the KDoc now says so plainly. A whole-series delete leaves it standing where an exception row would have gone with the parent; a calendar move leaves it behind; a series-wide *time* edit moves the generated instances but not the absolute-instant EXDATE hole, so the occurrence returns alongside the copy; and building the row from the form rather than cloning the parent drops `ORGANIZER`, `STATUS` and the organizer/resource attendee rows, exactly as `moveEvent` does.
The EXDATE staleness is not new — a #47 delete resurrects the same way after a series time edit — but a duplicate is a louder symptom than a resurrection. It wants fixing at the series-update end (re-stamping EXDATE alongside the DTSTART shift in `buildEventUpdateValues`, and carrying surviving stamps into the split series in `updateEventFromOccurrence`), which is a change to the "all events" path for *every* calendar type and does not belong in a targeted fix. Worth its own issue.
## How it was verified
- `./gradlew :app:testDebugUnitTest` — BUILD SUCCESSFUL, 62 suites, 0 failures.
- `./gradlew :app:lintDebug` — BUILD SUCCESSFUL, no new findings.
- `./gradlew :app:assembleDebug` — BUILD SUCCESSFUL.
- Independent adversarial code review, whose findings drove the second commit (the double-detach guard, the corrected rollback claim, and the cost documentation above). It confirmed no interleaving loses an occurrence, and cleared the drag-to-reschedule path: `RescheduleViewModel.undoFor` returns null for a recurring single-occurrence move, so the changed return value (a new event id rather than an exception id) never reaches the undo machinery.
New JVM tests cover the pure halves: the rule is dropped and the row becomes a one-off with DTEND, every edited field survives onto the inserted columns, all-day stays on UTC midnights, the detached row's DTSTART agrees with the EXDATE stamp that removes it from the parent (timed and all-day), and `exdateContains` recognises an already-excluded occurrence without matching a neighbouring one.
## What still needs a device
The provider behaviour itself cannot be confirmed on the JVM — `AndroidCalendarDataSource` has no fake-resolver harness, so `detachOccurrence`'s branch, its insert-then-EXDATE ordering and its rollback have no unit coverage. On a device, on a **local or unsynced** calendar:
1. The reported case end to end: recurring series, edit one occurrence's title, "Only this event" — the edit sticks, that occurrence alone changes, and the rest of the series survives (the #47 collapse must not reappear).
2. The same for an **all-day** yearly series (a contact birthday calendar is the natural subject) — the date-only EXDATE form excludes the right day, not the one before it.
3. A series pinned to a **non-device timezone**, and an occurrence across a **DST boundary** — the hole and the detached row must land on the same instant.
4. Editing the occurrence's **time**, not just its title, and editing the **first** occurrence of a series (DTSTART then points at an excluded instant — expected to be fine, same property the #47 delete path already has, but untested).
5. Reminders and guests on the detached row, and a colour from an account palette.
6. Regression on a **DAVx5 / Google synced** calendar: "Only this event" must still go down the exception path and behave exactly as before.
7. Drag-to-reschedule a single occurrence on an unsynced series — same path, different caller.
Closes#234
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/245
The month and agenda widgets did not roll over at midnight. They kept highlighting yesterday as "today", and the agenda kept dimming events against yesterday, until the user paged the month arrows or removed and re-added the widget.
**What was wrong**
The manifest asked for `DATE_CHANGED` and `WidgetUpdateReceiver`'s docs presented it as the rollover mechanism, but `DATE_CHANGED` is not on the implicit-broadcast exemption list, so a manifest-declared receiver has not been given it since Android 8. That left only `updatePeriodMillis`, which the system defers in doze and OEM skins throttle harder still. The data layer was fine all along - the month cache guard already drops its window when the anchor date changes, which is why an arrow tap fixed it instantly.
**What changed**
- `WidgetRolloverScheduler` arms a single alarm for just after the next local midnight and re-arms on every firing, the same shape as `ReminderAlarmScheduler`. Inexact (`setAndAllowWhileIdle`): no permission, survives doze, and exact alarms stay reserved for reminder snooze. It targets the actual start of day, not a literal 00:00, so it holds where DST means midnight never happens.
- Armed only while a widget is placed, via `onEnabled`/`onDisabled` on both Glance receivers; `sync()` cancels only when neither kind is left. Re-armed from boot, package-replace, time and timezone changes, app start (which is what arms existing installs upgrading into this), and from `onUpdate`, so an alarm dropped by a force-stop or an OEM freeze heals itself.
- Paging the month widget forward and back no longer pins it to that month. `ShiftMonthAction` stored an absolute index on every tap, so the workaround people used to force a redraw quietly stranded the widget on whatever month was current at the time.
- `DATE_CHANGED` stays in the filter as a free extra, but nothing depends on it and the docs no longer claim otherwise.
- Keep rule extended to `GlanceAppWidgetReceiver`, since the two receivers are now as structurally alike as the widgets that #89 collapsed.
**Verification**
`testDebugUnitTest` (775 tests, 0 failures), `lintDebug` and `assembleDebug` all pass. `assembleReleaseTest` builds and the R8 mapping confirms `MonthWidget`, `AgendaWidget` and both receivers keep their real names. 12 new unit tests cover the next-midnight arithmetic: ordinary days, the re-arming instant itself, both DST directions on frozen historical transitions, a zone whose midnight does not exist, a half-hour offset, two zones seeing the same instant, and that the receiver's action guard admits the alarm's action.
Not verified on a device - the overnight rollover on an OxygenOS-class device is the one thing that needs a real test before release.
Closes#228.
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/246
Calendula is published on Google Play, so the pipeline and the docs stop treating it as pending.
**README**
- Google Play badge next to F-Droid/Obtainium, linking the store page.
- Install table: the Play row goes from "Coming soon / —" to a real channel.
- The "coming soon" section becomes a live one with the store link; the closed-testing "testers wanted" call drops out. The note that Play signs with Google's key (so switching channels needs an uninstall) stays, and the trailing shared-signing-key line now flags Play as the exception.
**Play track**
- Release uploads default to `production` instead of `internal` — in the lane (`fastlane/Fastfile`) and in the workflow default (`.gitea/workflows/release.yaml`). Merging a bumped versionName to main is already the human gate, so the manual Console promotion only added delay.
- `docs/RELEASING.md` updated to match, including the escape hatches (`PLAY_TRACK=internal` to stage, `PLAY_RELEASE_STATUS=draft` to hold).
Note: if a `PLAY_TRACK` repo variable is set on Gitea it still wins over the new default — needs checking there.
No issue for this one.
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/244