diff --git a/CHANGELOG.md b/CHANGELOG.md index 30a32f9..1513cf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Imported reminders fire at the hour you chose**, not at midnight UTC — the same rule the app already applied to all-day events you create yourself ([#225]). -- **An all-day event with no end date, or one ending the day it starts, imports - as a one-day event** instead of a zero-length one that never appears ([#225]). +- **Birthdays and anniversaries imported from Fossify Calendar now show up.** + Fossify writes the ones it mirrors from your contacts as events that start and + end on the same day, which the calendar read as lasting no time at all: they + imported without complaint and then appeared nowhere. Any all-day event that + ends where it starts, or carries no end at all, is now a one-day event + ([#225]). ### Added - **More of an imported `.ics` survives the trip**: tasks come across as events diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index bf7e7f4..3e9c05b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -795,8 +795,12 @@ class AndroidCalendarDataSource @Inject constructor( override fun existingUids(calendarId: Long): Set = resolver.query( CalendarContract.Events.CONTENT_URI, arrayOf(CalendarContract.Events.UID_2445), + // DELETED rows linger until a sync adapter purges them; counting those + // as present would make a re-import skip everything the user has since + // deleted, reporting "all duplicates" and importing nothing. "${CalendarContract.Events.CALENDAR_ID} = ? AND " + - "${CalendarContract.Events.UID_2445} IS NOT NULL", + "${CalendarContract.Events.UID_2445} IS NOT NULL AND " + + "${CalendarContract.Events.DELETED} = 0", arrayOf(calendarId.toString()), null, )?.use { c -> diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt index ce33cec..881ebbe 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt @@ -10,12 +10,15 @@ import org.junit.jupiter.api.Test * Every fixture is shaped as `IcsExporter.writeEvent` emits it — property order, * the `X-FOSSIFY-*` extensions and the `P0DT1H5M0S` duration spelling included. * - * Note what is *not* asserted here: their all-day `DTEND` is RFC-correct. - * `Event.endTS` anchors an all-day event at noon of its last day + * Their all-day `DTEND` is *usually* RFC-correct and must not be "corrected": + * `Event.endTS` anchors a UI-created all-day event at noon of its last day * (`EventActivity.getStartEndTimes`), or at the exclusive midnight for a * CalDAV-sourced one, and the exporter's `+ TWELVE_HOURS` rounds either to the - * following midnight. Treating it as inclusive would push every imported all-day - * event out by a day. + * following midnight. Shifting those by a day would corrupt them. + * + * The exception — and the whole of #225 — is events mirrored from Contacts. + * `MainActivity` builds those with `startTS = endTS = timestamp`, so `+ 12h` + * lands back on the starting day and they export zero length. */ class IcsFossifyImportTest { @@ -53,6 +56,30 @@ class IcsFossifyImportTest { assertThat(days(event)).isEqualTo(1) } + @Test + fun `contact-mirrored birthdays export zero length and must not vanish`() { + // The reported failure (#225). Fossify mirrors Contacts birthdays and + // anniversaries with startTS == endTS, so the exporter's +12h rounds + // back to the starting day: DTSTART == DTEND. Read literally these are + // yearly series of zero-length occurrences, which the provider expands + // into nothing at all — the birthdays import "successfully" and are + // then nowhere to be seen. + val text = checkNotNull( + javaClass.classLoader?.getResourceAsStream("ics/fossify-contact-birthdays.ics"), + ).use { it.readBytes().toString(Charsets.UTF_8) } + + val result = parser.parse(text) + + assertThat(result.events).hasSize(3) + result.events.forEach { + assertThat(it.isAllDay).isTrue() + assertThat(days(it)).isEqualTo(1) + assertThat(it.recurrenceRule).startsWith("FREQ=YEARLY") + // Their all-day reminder encoding: "on the day", not midnight UTC. + assertThat(it.semanticReminderMinutes()).containsExactly(0) + } + } + @Test fun `a real Fossify holiday file imports unchanged`() { // Shipped inside Fossify Calendar (assets/holidays/AT/public.ics). Its diff --git a/app/src/test/resources/ics/fossify-contact-birthdays.ics b/app/src/test/resources/ics/fossify-contact-birthdays.ics new file mode 100644 index 0000000..6c9a7b8 --- /dev/null +++ b/app/src/test/resources/ics/fossify-contact-birthdays.ics @@ -0,0 +1,64 @@ +BEGIN:VCALENDAR +PRODID:-//Fossify//NONSGML Event Calendar//EN +VERSION:2.0 +BEGIN:VEVENT +SUMMARY:Anna Schmidt +UID:101 +X-FOSSIFY-CATEGORY-COLOR:-1155931 +CATEGORIES:Birthdays +LAST-MODIFIED:20250615T150640Z +TRANSP:TRANSPARENT +DTSTART;VALUE=DATE:19900412 +DTEND;VALUE=DATE:19900412 +X-FOSSIFY-MISSING-YEAR:0 +DTSTAMP:20260819T120000Z +CLASS:PUBLIC +STATUS:CONFIRMED +RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4 +BEGIN:VALARM +DESCRIPTION:Reminder +ACTION:DISPLAY +TRIGGER:P0DT9H0M0S +END:VALARM +END:VEVENT +BEGIN:VEVENT +SUMMARY:Opa +UID:102 +X-FOSSIFY-CATEGORY-COLOR:-1155931 +CATEGORIES:Birthdays +LAST-MODIFIED:20250615T150640Z +TRANSP:TRANSPARENT +DTSTART;VALUE=DATE:19700603 +DTEND;VALUE=DATE:19700603 +X-FOSSIFY-MISSING-YEAR:1 +DTSTAMP:20260819T120000Z +CLASS:PUBLIC +STATUS:CONFIRMED +RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=6 +BEGIN:VALARM +DESCRIPTION:Reminder +ACTION:DISPLAY +TRIGGER:P0DT9H0M0S +END:VALARM +END:VEVENT +BEGIN:VEVENT +SUMMARY:Hochzeitstag +UID:103 +X-FOSSIFY-CATEGORY-COLOR:-1155931 +CATEGORIES:Anniversaries +LAST-MODIFIED:20250615T150640Z +TRANSP:TRANSPARENT +DTSTART;VALUE=DATE:20050917 +DTEND;VALUE=DATE:20050917 +X-FOSSIFY-MISSING-YEAR:0 +DTSTAMP:20260819T120000Z +CLASS:PUBLIC +STATUS:CONFIRMED +RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=9 +BEGIN:VALARM +DESCRIPTION:Reminder +ACTION:DISPLAY +TRIGGER:P0DT9H0M0S +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8f7b2c0..36d87be 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -352,22 +352,28 @@ alert row behind it. by other people's calendars. What that costs us, learned from reading the Simple Calendar / Fossify exporter (#225): -**An all-day event may never reach the provider zero days long.** RFC 5545 -§3.6.1 gives a `DATE`-valued `DTSTART` with no `DTEND` a one-day duration, and a -`DTEND` equal to `DTSTART` is meaningless — but the failure is silent rather than -loud: the provider expands a zero-length series into no instances at all, so the -event is simply not there, with nothing logged. `resolveEnd` floors the span at a -day and `buildImportedEventValues` floors an all-day `DURATION` at `P1D`. +**An all-day event may never reach the provider zero days long.** This is what +#225 was: 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`. Taken literally +that is a yearly series of zero-length occurrences, which the provider expands +into no instances at all — the import reports success and the birthdays are +nowhere. Nothing is logged, because nothing failed. `resolveEnd` floors an +all-day span at a day (also RFC 5545 §3.6.1, which gives a `DATE` `DTSTART` with +no `DTEND` a one-day duration) and `buildImportedEventValues` floors an all-day +`DURATION` at `P1D`. -Resist the urge to correct a producer's all-day `DTEND` by sniffing its `PRODID`. -Fossify's looks like it needs it and does not: `Event.endTS` anchors an all-day -event at *noon* of its last day (`EventActivity.getStartEndTimes`), or at the -exclusive midnight when the row came from CalDAV, and the exporter's -`+ TWELVE_HOURS` rounds either to the following midnight — correct on both paths. -Shifting it would push every imported all-day event out by a day. The holiday -files bundled inside Fossify are a second trap: their `PRODID` reads +Do **not** generalise that into correcting the producer's `DTEND` by sniffing its +`PRODID`. The same exporter is right everywhere else: `Event.endTS` anchors a +UI-created all-day event at *noon* of its last day +(`EventActivity.getStartEndTimes`), or at the exclusive midnight when the row +came from CalDAV, and `+ TWELVE_HOURS` rounds either to the following midnight. +Shifting those would push every one of them out by a day. The holiday files +bundled inside Fossify are a second trap: their `PRODID` reads `Fossify Calendar Holiday Generator` and their content is plain conformant -iCalendar. One is kept as a fixture in `app/src/test/resources/ics`. +iCalendar. Both that file and a contact-birthday export are kept as fixtures in +`app/src/test/resources/ics`. Clamp the degenerate case; never rewrite the +conformant one. **One bad event must not cost the file.** The provider validates `RRULE` through `EventRecurrence.parse`, which *throws* — out of `insert`, not out of a later