fix(import): don't lose Fossify's contact birthdays and anniversaries (#225)

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
This commit is contained in:
2026-08-19 20:38:41 +02:00
parent 3e3ff4fd2f
commit c0361686df
5 changed files with 126 additions and 21 deletions

View File

@@ -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 - **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 same rule the app already applied to all-day events you create yourself
([#225]). ([#225]).
- **An all-day event with no end date, or one ending the day it starts, imports - **Birthdays and anniversaries imported from Fossify Calendar now show up.**
as a one-day event** instead of a zero-length one that never appears ([#225]). 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 ### Added
- **More of an imported `.ics` survives the trip**: tasks come across as events - **More of an imported `.ics` survives the trip**: tasks come across as events

View File

@@ -795,8 +795,12 @@ class AndroidCalendarDataSource @Inject constructor(
override fun existingUids(calendarId: Long): Set<String> = resolver.query( override fun existingUids(calendarId: Long): Set<String> = resolver.query(
CalendarContract.Events.CONTENT_URI, CalendarContract.Events.CONTENT_URI,
arrayOf(CalendarContract.Events.UID_2445), 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.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()), arrayOf(calendarId.toString()),
null, null,
)?.use { c -> )?.use { c ->

View File

@@ -10,12 +10,15 @@ import org.junit.jupiter.api.Test
* Every fixture is shaped as `IcsExporter.writeEvent` emits it — property order, * Every fixture is shaped as `IcsExporter.writeEvent` emits it — property order,
* the `X-FOSSIFY-*` extensions and the `P0DT1H5M0S` duration spelling included. * the `X-FOSSIFY-*` extensions and the `P0DT1H5M0S` duration spelling included.
* *
* Note what is *not* asserted here: their all-day `DTEND` is RFC-correct. * Their all-day `DTEND` is *usually* RFC-correct and must not be "corrected":
* `Event.endTS` anchors an all-day event at noon of its last day * `Event.endTS` anchors a UI-created all-day event at noon of its last day
* (`EventActivity.getStartEndTimes`), or at the exclusive midnight for a * (`EventActivity.getStartEndTimes`), or at the exclusive midnight for a
* CalDAV-sourced one, and the exporter's `+ TWELVE_HOURS` rounds either to the * 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 * following midnight. Shifting those by a day would corrupt them.
* event out by a day. *
* 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 { class IcsFossifyImportTest {
@@ -53,6 +56,30 @@ class IcsFossifyImportTest {
assertThat(days(event)).isEqualTo(1) 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 @Test
fun `a real Fossify holiday file imports unchanged`() { fun `a real Fossify holiday file imports unchanged`() {
// Shipped inside Fossify Calendar (assets/holidays/AT/public.ics). Its // Shipped inside Fossify Calendar (assets/holidays/AT/public.ics). Its

View File

@@ -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

View File

@@ -352,22 +352,28 @@ alert row behind it.
by other people's calendars. What that costs us, learned from reading the Simple by other people's calendars. What that costs us, learned from reading the Simple
Calendar / Fossify exporter (#225): Calendar / Fossify exporter (#225):
**An all-day event may never reach the provider zero days long.** RFC 5545 **An all-day event may never reach the provider zero days long.** This is what
§3.6.1 gives a `DATE`-valued `DTSTART` with no `DTEND` a one-day duration, and a #225 was: Fossify mirrors Contacts birthdays and anniversaries with
`DTEND` equal to `DTSTART` is meaningless — but the failure is silent rather than `startTS = endTS` (`MainActivity`), so its exporter's `dayCode(endTS + 12h)`
loud: the provider expands a zero-length series into no instances at all, so the rounds back to the starting day and writes `DTEND == DTSTART`. Taken literally
event is simply not there, with nothing logged. `resolveEnd` floors the span at a that is a yearly series of zero-length occurrences, which the provider expands
day and `buildImportedEventValues` floors an all-day `DURATION` at `P1D`. 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`. Do **not** generalise that into correcting the producer's `DTEND` by sniffing its
Fossify's looks like it needs it and does not: `Event.endTS` anchors an all-day `PRODID`. The same exporter is right everywhere else: `Event.endTS` anchors a
event at *noon* of its last day (`EventActivity.getStartEndTimes`), or at the UI-created all-day event at *noon* of its last day
exclusive midnight when the row came from CalDAV, and the exporter's (`EventActivity.getStartEndTimes`), or at the exclusive midnight when the row
`+ TWELVE_HOURS` rounds either to the following midnight — correct on both paths. came from CalDAV, and `+ TWELVE_HOURS` rounds either to the following midnight.
Shifting it would push every imported all-day event out by a day. The holiday Shifting those would push every one of them out by a day. The holiday files
files bundled inside Fossify are a second trap: their `PRODID` reads bundled inside Fossify are a second trap: their `PRODID` reads
`Fossify Calendar Holiday Generator` and their content is plain conformant `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 **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 `EventRecurrence.parse`, which *throws* — out of `insert`, not out of a later