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:
@@ -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
|
||||
|
||||
@@ -795,8 +795,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
override fun existingUids(calendarId: Long): Set<String> = 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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
64
app/src/test/resources/ics/fossify-contact-birthdays.ics
Normal file
64
app/src/test/resources/ics/fossify-contact-birthdays.ics
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user