diff --git a/.gitattributes b/.gitattributes
index 9ac4914..9e860d9 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -5,3 +5,4 @@
*.jpg binary
*.gif binary
*.webp binary
+app/src/test/resources/ics/*.ics -text
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c7cbe5..9e49e23 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [2.19.4] — 2026-09-01
+
### Fixed
- **A deleted occurrence no longer comes back when the series is re-timed.**
Delete a single occurrence of a repeating event, then change the time of the
@@ -18,6 +20,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
carry over when a series is split. Repeating events on your device's own
calendars are affected, including the birthday and anniversary calendars
Calendula creates from your contacts ([#248]).
+- **Home-screen widgets now turn the page at midnight.** Both the month and the
+ agenda widget kept highlighting yesterday as "today" — and the agenda kept
+ greying out the wrong events as already past — until you paged the month back
+ and forth or removed and re-added the widget. Calendula now wakes itself at the
+ day boundary and redraws, and re-arms after a reboot, a clock change or a
+ flight into another timezone. Paging the month widget forward and back also
+ stops quietly pinning it to that month, so it follows the date again instead of
+ being stranded on the month you happened to be looking at ([#228]).
+- **"Only this event" now actually saves your edit.** On some calendars —
+ including Google ones that still show as on-device, and any local calendar —
+ editing a single occurrence of a repeating event did nothing at all: the scope
+ dialog closed, the edit screen stayed put, and saving again just repeated it.
+ Android can only attach a single-occurrence change to its series once the
+ calendar has been synced at least once, so on those calendars the change had
+ nowhere to go. Calendula now removes that one occurrence from the series and
+ saves the edit as its own event instead, which is what you see either way. A
+ save that does fail also says so for longer, rather than flashing past
+ ([#234]).
+- **A failed import no longer costs you the whole file.** One event the calendar
+ refuses used to abort the entire import with nothing on screen but "couldn't
+ read this file". Events are now added one at a time: anything rejected is
+ counted and shown, and the rest still arrive. A faulty repeat rule in the file
+ is repaired instead of being handed on ([#225]).
+- **All-day reminders survive a backup and an import.** They fire at the hour you
+ chose, not at midnight UTC — the same rule the app already applied to all-day
+ events you create yourself — and an export no longer leaves them out of the
+ file entirely. An imported yearly birthday fires at the right hour too, even
+ though the file dates it back to the year of birth ([#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]).
+- **A backup no longer brings back occurrences you had deleted.** Remove a single
+ occurrence of a repeating event, back up, restore — and it was there again,
+ because the removals never made it into the file. They travel with the series
+ now, so a restored calendar looks the way you left it ([#225]).
+- **A reminder left on your account's default no longer went off as the event
+ started.** An event whose reminder says "Default" is the account's to time, not
+ Calendula's — it was reading that setting as a lead time of its own and firing a
+ minute after the event began. It now leaves such a reminder alone, and says so
+ on the event.
+- **"Only this event" on an event that doesn't repeat now edits or deletes it.**
+ Deleting used to write an exclusion onto a one-off event instead — which left
+ the event there and lost its end time — and editing filed the change away as an
+ exception to nothing, so it vanished.
+- **An import that the calendar cuts short now tells you how far it got.** Losing
+ the calendar or the permission part way through a big file reported the whole
+ import as failed, so importing again added everything twice.
+- **An event that runs past midnight can be dragged by either half.** Its second
+ half couldn't be picked up at all, and the first could only be moved later. Both
+ halves now move, and both are previewed while you hold either one, so the event
+ reads as moving rather than growing. The blocks are cut square at midnight
+ instead of rounded off, a month row draws an event crossing into the next week
+ as one bar, and a move across a daylight-saving change no longer lands an hour
+ out ([#253]).
+
+### Added
+- **More of an imported `.ics` survives the trip**: tasks come across as events
+ rather than being dropped in silence, deleted occurrences of a repeating event
+ stay deleted, a calendar named like the one the file came from is preselected,
+ and event colours are carried over — matched to the closest colour your
+ calendar's account offers, so a migrated calendar still reads at a glance
+ ([#225]).
+- **Belarusian, Hungarian and Slovak**, all three contributed as community
+ translations through
+ [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
+ Belarusian covers about nine tenths of the app, Slovak about a third and
+ Hungarian is just under way, so untranslated parts still show in English until
+ they fill out. Pick any of them under Settings → Language or in Android's
+ per-app language settings. Thanks to Dmitri Gabinski (Belarusian), Ábel Nagy
+ (Hungarian) and Julie (Slovak); help finishing them is very welcome.
+
+### Changed
+- **Arabic, Polish and Simplified Chinese are now complete**, all three carried
+ the rest of the way this cycle — Simplified Chinese from a fifth of the app to
+ all of it. Czech and Russian grew too — Russian to about half — and still have
+ room. Thanks to Sup99999 (Arabic), Bazyli Cyran (Polish), JeanWenzel
+ (Simplified Chinese), James Trivette (Czech), and SpiritOTHawk and Artur
+ (Russian).
## [2.19.3] — 2026-08-22
@@ -1483,4 +1566,8 @@ automatically, with zero telemetry and no internet permission.
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
+[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
+[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
+[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
+[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index eac36ca..3dae3a6 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
- versionCode = 21903
- versionName = "2.19.3"
+ versionCode = 21904
+ versionName = "2.19.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index ef98b3a..6181354 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -50,3 +50,9 @@
# the real names also survives app updates, which would otherwise renumber the
# obfuscated name and orphan the stored mapping.
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
+
+# Belt and braces one level up: the two receivers are nearly as alike, and the
+# provider map is keyed off the receiver component too. Redundant today (AGP's
+# manifest-derived rules cover them), but #89 cost a release to diagnose and the
+# guarantee should not rest on a component staying in the manifest.
+-keep class * extends androidx.glance.appwidget.GlanceAppWidgetReceiver
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1db01ea..a7e97b6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -330,9 +330,13 @@
+ PROVIDER_CHANGED on any data change (our writes and external sync).
+ The day boundary arrives as the app's own ROLLOVER alarm (#228), by
+ explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
+ a free extra only, since Android 8+ withholds it from manifest
+ receivers. The four below re-arm that alarm: TIME_SET /
+ TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
+ Exported: the system broadcasts arrive from outside the app. -->
@@ -346,6 +350,8 @@
+
+
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt
index c11701e..e705be3 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
+import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope
@@ -44,6 +45,19 @@ class CalendulaApp : Application() {
reconcileSpecialDates()
reconcileCalendarVisibility()
startReminderDelivery()
+ reconcileWidgetRollover()
+ }
+
+ /**
+ * Re-arm the widgets' midnight rollover from whatever is actually placed
+ * (#228). Idempotent, and it covers what no broadcast reaches — an alarm
+ * dropped by a force-stop is armed again the next time the app is opened.
+ * Off the main thread: a handful of binder calls on every process start.
+ */
+ private fun reconcileWidgetRollover() {
+ CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
+ WidgetRolloverScheduler.sync(this@CalendulaApp)
+ }
}
/**
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt
index 94021e6..1ed2f59 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt
@@ -31,9 +31,13 @@ private const val MILLIS_PER_MINUTE = 60_000L
* event (a whole-day multiple; sub-day remainders are dropped), so it fires at
* [timeOfDayMinutes] (minutes from local midnight) in [zone]. The result may be
* **negative** — e.g. "at time of event" at 09:00 CEST encodes to −420, meaning
- * the provider fires *after* DTSTART; this is valid and must not be clamped.
- * A negative [semanticMinutes] is the "provider default" sentinel and passes
- * through unchanged.
+ * the provider fires *after* DTSTART; this is valid and must not be clamped. The
+ * one negative it never is is [MINUTES_DEFAULT]: the sentinel and the encoding
+ * share a value space, so an encoding that lands on it is nudged one minute past
+ * it rather than being read back as "use the account default". That sentinel is
+ * also the one [semanticMinutes] that passes through unencoded — a lead time may
+ * itself be negative (a foreign row firing the day *after* the event), and
+ * waving those through would change when they fire.
*/
internal fun toProviderAllDayMinutes(
semanticMinutes: Int,
@@ -41,12 +45,13 @@ internal fun toProviderAllDayMinutes(
zone: ZoneId,
timeOfDayMinutes: Int,
): Int {
- if (semanticMinutes < 0) return semanticMinutes
+ if (semanticMinutes == MINUTES_DEFAULT) return semanticMinutes
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
.atZone(zone).toInstant().toEpochMilli()
- return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
+ val raw = ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
+ return if (raw == MINUTES_DEFAULT) MINUTES_DEFAULT - 1 else raw
}
/**
@@ -70,15 +75,52 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
return LocalDate.of(today.year, month, day)
}
+/**
+ * The date an **imported** all-day event's reminder offset is sampled at.
+ *
+ * A one-off fires once, on its own date. A series' `DTSTART` is only an anchor
+ * and may be ancient — Fossify writes a year-less contact birthday at 1970, a
+ * year whose rules predate DST across most of Europe, and a real birth year is
+ * usually older still — so sampling there skews every modern occurrence by the
+ * offset delta (the same trap [nextYearlyOccurrence] exists for). A yearly
+ * series is therefore sampled at its next occurrence of the anchor's month/day,
+ * which shares the season; any other series at [today], which its upcoming
+ * occurrences are near.
+ *
+ * The decode side ([fromProviderAllDayMinutes]) only asks which local *day* the
+ * encoded instant falls on, so it still reads the lead time back correctly from
+ * the anchor's own date.
+ */
+internal fun importedAllDayReminderDate(
+ startDate: LocalDate,
+ recurrenceRule: String?,
+ today: LocalDate,
+): LocalDate = when {
+ recurrenceRule.isNullOrBlank() -> startDate
+ recurrenceRule.contains("FREQ=YEARLY", ignoreCase = true) ->
+ nextYearlyOccurrence(startDate.monthValue, startDate.dayOfMonth, today)
+ startDate.isBefore(today) -> today
+ else -> startDate
+}
+
/**
* Recover the semantic whole-day lead time from a raw all-day reminder
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
* the reminder actually fires on.
+ *
+ * [MINUTES_DEFAULT] passes through unchanged, the mirror of the encode side. A
+ * real encoding is often negative, so only that exact value may be treated as the
+ * sentinel — decoding it would land it on the start date and turn "use the
+ * account default" into a concrete at-start alarm.
*/
internal fun fromProviderAllDayMinutes(
rawMinutes: Int,
startDate: LocalDate,
zone: ZoneId,
timeOfDayMinutes: Int,
-): Int = allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
+): Int = if (rawMinutes == MINUTES_DEFAULT) {
+ rawMinutes
+} else {
+ allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
+}
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 9f3e74d..9417d96 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
@@ -35,6 +35,7 @@ import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
+import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import kotlinx.datetime.toJavaLocalDate
import java.time.Instant
@@ -84,14 +85,25 @@ interface CalendarDataSource {
*/
fun eventColorPalette(calendarId: Long): List
+ /**
+ * The same palette **uncurated** — every key the account publishes, in
+ * provider order. Curation is a display concession (it folds look-alikes and
+ * drops the neutrals outright from an oversized palette), so matching a
+ * colour that came from outside the account has to run against the full set:
+ * those keys are all the calendar accepts. See [eventColorPalette].
+ */
+ fun publishedEventColors(calendarId: Long): List
+
/**
* Every master/one-off event of the writable local calendars, mapped for a
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
* given, only those calendars are exported (still intersected with the
* eligible set); `null` exports every eligible calendar.
+ * [allDayReminderTimeMinutes]: needed to write all-day reminders as whole-day
+ * lead times rather than raw provider offsets (see [toIcsEvent]).
*/
- fun exportableEvents(calendarIds: Set? = null): List
+ fun exportableEvents(calendarIds: Set?, allDayReminderTimeMinutes: Int): List
/**
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
@@ -101,10 +113,19 @@ interface CalendarDataSource {
/**
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
- * minting one when absent); returns the new `Events._ID`. Reminders are
- * written as the file's raw lead minutes (METHOD_ALERT).
+ * minting one when absent); returns the new `Events._ID`.
+ *
+ * [colorPalette] is the target account's published event colours
+ * ([publishedEventColors]), looked up once per import;
+ * [allDayReminderTimeMinutes] is the user's preferred all-day firing time,
+ * applied exactly as a hand-created event's is.
*/
- fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
+ fun insertImportedEvent(
+ event: ParsedIcsEvent,
+ calendarId: Long,
+ allDayReminderTimeMinutes: Int,
+ colorPalette: List,
+ ): Long
/**
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
@@ -232,14 +253,21 @@ interface CalendarDataSource {
): Long
/**
- * Change a single occurrence of a recurring event by inserting a
- * modified-occurrence exception at [beginMillis] (the occurrence's
- * `Instances.BEGIN`) carrying [form]'s values; returns the exception
- * row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
+ * Change a single occurrence of a recurring event at [beginMillis] (the
+ * occurrence's `Instances.BEGIN`) to [form]'s values; returns the
+ * `Events._ID` of the row now holding them.
+ *
+ * A series with a `_sync_id` gets a modified-occurrence exception. One
+ * without gets the occurrence excluded from the parent via EXDATE plus a
+ * standalone event carrying the edits — an exception cannot link to its
+ * parent there (Codeberg #234, the same constraint as [deleteOccurrence]).
+ * A row that turns out not to recur at all is edited whole, which is what
+ * [original] is for. [allDayReminderTimeMinutes]: see [insertEvent].
*/
fun updateOccurrence(
eventId: Long,
beginMillis: Long,
+ original: EventForm,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long
@@ -721,7 +749,10 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
- override fun eventColorPalette(calendarId: Long): List {
+ override fun eventColorPalette(calendarId: Long): List =
+ publishedEventColors(calendarId).curatedForPicker()
+
+ override fun publishedEventColors(calendarId: Long): List {
val account = calendarAccount(calendarId) ?: return emptyList()
return resolver.query(
CalendarContract.Colors.CONTENT_URI,
@@ -739,11 +770,13 @@ class AndroidCalendarDataSource @Inject constructor(
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
}
?.filter { it.key.isNotEmpty() }
- ?.curatedForPicker()
?: emptyList()
}
- override fun exportableEvents(calendarIds: Set?): List {
+ override fun exportableEvents(
+ calendarIds: Set?,
+ allDayReminderTimeMinutes: Int,
+ ): List {
// Only the local calendars the app owns and can write — synced calendars
// already have a backup (their server). Exclude the managed special-dates
// mirror calendars: their events are derived from contacts, not authored
@@ -778,6 +811,7 @@ class AndroidCalendarDataSource @Inject constructor(
reader.toIcsEvent(
reminderMinutes = queryReminders(eventId).map { it.minutes },
calendarName = names[calendarId],
+ allDayReminderTimeMinutes = allDayReminderTimeMinutes,
)
}
} ?: emptyList()
@@ -786,76 +820,83 @@ 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 ->
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
} ?: emptySet()
- override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
- val startMillis = event.start.toEpochMillis()
- val endMillis = event.end.toEpochMillis()
- val values = ContentValues().apply {
- put(CalendarContract.Events.CALENDAR_ID, calendarId)
+ override fun insertImportedEvent(
+ event: ParsedIcsEvent,
+ calendarId: Long,
+ allDayReminderTimeMinutes: Int,
+ colorPalette: List,
+ ): Long {
+ val values = buildImportedEventValues(
+ event = event,
+ calendarId = calendarId,
// Preserve the file's UID so a re-import dedups against it; mint one
// only when the source event carried none.
- put(
- CalendarContract.Events.UID_2445,
- event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
- )
- put(CalendarContract.Events.TITLE, event.summary.trim())
- put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
- put(CalendarContract.Events.DTSTART, startMillis)
- if (event.recurrenceRule == null) {
- put(CalendarContract.Events.DTEND, endMillis)
- } else {
- put(CalendarContract.Events.RRULE, event.recurrenceRule)
- put(
- CalendarContract.Events.DURATION,
- importDuration(startMillis, endMillis, event.isAllDay),
- )
- }
- // All-day rows live at UTC midnights (the file already encodes them so);
- // timed rows keep the event's own zone.
- put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
- put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
- put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
- event.location?.trim()?.takeIf { it.isNotEmpty() }
- ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
- event.description?.trim()?.takeIf { it.isNotEmpty() }
- ?.let { put(CalendarContract.Events.DESCRIPTION, it) }
- }
- val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
+ uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
+ palette = colorPalette,
+ )
+ val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
?: throw WriteFailedException("import event into calendar id=$calendarId")
val eventId = ContentUris.parseId(uri)
- // Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
- event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
+ val zone = ZoneId.systemDefault()
+ // An all-day reminder is stored the same way a hand-created one is, so it
+ // fires at the time the user picked rather than at UTC midnight — sampled
+ // where it will actually fire, not at an ancient recurrence anchor
+ // (see [importedAllDayReminderDate]).
+ val reminderDate = if (event.isAllDay) {
+ importedAllDayReminderDate(
+ startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds())
+ .atZone(ZoneOffset.UTC).toLocalDate(),
+ recurrenceRule = event.recurrenceRule,
+ today = LocalDate.now(zone),
+ )
+ } else {
+ null
+ }
+ event.semanticReminderMinutes().forEach { minutes ->
+ val providerMinutes = if (reminderDate != null) {
+ toProviderAllDayMinutes(
+ semanticMinutes = minutes,
+ startDate = reminderDate,
+ zone = zone,
+ timeOfDayMinutes = allDayReminderTimeMinutes,
+ )
+ } else {
+ minutes
+ }
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
- put(CalendarContract.Reminders.MINUTES, minutes)
+ put(CalendarContract.Reminders.MINUTES, providerMinutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
- if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
+ // The event row is already in. A reminder that won't attach costs
+ // that one alarm, not the event: throwing here would have the import
+ // count an event it did create as failed, and the retry would then
+ // skip it as a duplicate.
+ val attached = try {
+ resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
+ } catch (e: Exception) {
+ Log.w(TAG, "Reminder insert threw for imported event $eventId", e)
+ null
+ }
+ if (attached == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
}
}
return eventId
}
- /** Provider DURATION for an imported recurring row: whole days / seconds. */
- private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
- val span = (endMillis - startMillis).coerceAtLeast(0)
- return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
- }
-
- private fun EventStatus.toProviderStatus(): Int = when (this) {
- EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
- EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
- EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
- }
-
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
@@ -1193,9 +1234,22 @@ class AndroidCalendarDataSource @Inject constructor(
override fun updateOccurrence(
eventId: Long,
beginMillis: Long,
+ original: EventForm,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
+ val row = querySeriesRow(eventId)
+ // Not a series, so there is no occurrence to single out: the row *is* the
+ // event, and an exception with nothing to be an exception to would leave
+ // an orphan holding the edit (the same test deleteOccurrence makes).
+ if (!row.recurs) {
+ updateEvent(eventId, original, form, allDayReminderTimeMinutes)
+ return eventId
+ }
+ // EXDATE needs no _sync_id; a modified exception does.
+ if (row.syncId == null) {
+ return detachOccurrence(eventId, beginMillis, row, form, allDayReminderTimeMinutes)
+ }
// The provider clones the series row and applies these values on top.
val values = buildOccurrenceExceptionValues(
form = form,
@@ -1214,6 +1268,90 @@ class AndroidCalendarDataSource @Inject constructor(
return exceptionId
}
+ /**
+ * "Edit only this event" on a series with **no `_sync_id`**: drop the
+ * occurrence from the parent with EXDATE and insert the edited values as a
+ * standalone event on the same calendar.
+ *
+ * A modified exception attaches to its parent only through `ORIGINAL_SYNC_ID`,
+ * exactly like the cancelled one [deleteOccurrence] documents; with no
+ * `_sync_id` the link never forms and the edit is lost (Codeberg #234).
+ * EXDATE plus a standalone row needs no link — what a detached instance
+ * degrades to without a `RECURRENCE-ID` to carry it.
+ *
+ * The detached row keeps no stored link back to its series, so: it no longer
+ * travels with it ([moveEvent] copies the master and its `ORIGINAL_ID`
+ * children, and this is neither); its EXDATE hole is an absolute instant, so
+ * re-timing the whole series brings the occurrence back beside the copy (a
+ * #47 delete resurrects the same way); and it is built from the form, not
+ * cloned, so `ORGANIZER`, `STATUS` and the attendee rows [reconcileAttendees]
+ * preserves are dropped — the same limitation as [moveEvent].
+ *
+ * Insert first, so a failure leaves the series untouched
+ * ([updateEventFromOccurrence]'s discipline); roll the new row back if the
+ * EXDATE update then fails, since it would be a visible duplicate. The
+ * reverse order risks the worse outcome — an excluded occurrence with no
+ * replacement, i.e. an edit that quietly deletes.
+ */
+ private fun detachOccurrence(
+ eventId: Long,
+ beginMillis: Long,
+ row: SeriesRow,
+ form: EventForm,
+ allDayReminderTimeMinutes: Int,
+ ): Long {
+ // Already detached (or deleted) from a stale screen still pointing at the
+ // parent: the EXDATE merge would fold the repeat away and still report a
+ // changed row, quietly leaving a *second* standalone copy.
+ if (exdateContains(row.exdate, beginMillis, row.allDay != 0, row.timezone)) {
+ throw NoSuchEventException(eventId)
+ }
+ // Reminders, guests and colour come along like any new event, and so does
+ // a fresh UID — the detached row is a separate event now, and sharing the
+ // parent's would collide with it in .ics restore dedup.
+ val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
+ val values = buildOccurrenceExdateValues(
+ existingExdate = row.exdate,
+ occurrenceMillis = beginMillis,
+ dtStartMillis = row.dtStartMillis,
+ rrule = row.rrule,
+ rdate = row.rdate,
+ duration = row.duration,
+ timezone = row.timezone,
+ allDay = row.allDay,
+ )
+ // Rows touched, not occurrences excluded — 1 whenever the series row still
+ // exists. It catches the row disappearing under us, not an EXDATE the
+ // provider's expansion fails to match.
+ val updatedRows = try {
+ resolver.update(
+ ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
+ values.toContentValues(), null, null,
+ )
+ } catch (t: Throwable) {
+ rollBackDetached(detachedId)
+ throw t
+ }
+ if (updatedRows == 0) {
+ rollBackDetached(detachedId)
+ throw WriteFailedException(
+ "exdate occurrence for edit, event id=$eventId begin=$beginMillis",
+ )
+ }
+ return detachedId
+ }
+
+ /**
+ * Undo the standalone row [detachOccurrence] inserted before its EXDATE
+ * update failed. Best effort: the caller is already throwing, and the worst
+ * case is the duplicate we were avoiding — never a lost occurrence.
+ */
+ private fun rollBackDetached(detachedId: Long) {
+ runCatching { deleteEvent(detachedId) }.onFailure {
+ Log.w(TAG, "Failed to roll back detached occurrence $detachedId", it)
+ }
+ }
+
override fun updateEventFromOccurrence(
eventId: Long,
beginMillis: Long,
@@ -1262,7 +1400,12 @@ class AndroidCalendarDataSource @Inject constructor(
// exclusion means nothing on a row that doesn't recur.
if (updated.rrule.isNullOrBlank()) return
val carried = shiftedExdate(
- existingExdate = exdateAfter(parent.exdate, beginMillis, original.isAllDay),
+ existingExdate = exdateAfter(
+ parent.exdate,
+ beginMillis,
+ original.isAllDay,
+ parent.timezone,
+ ),
original = original,
updated = updated,
zone = ZoneId.systemDefault(),
@@ -1342,6 +1485,7 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Events.ALL_DAY,
CalendarContract.Events._SYNC_ID,
CalendarContract.Events.EXDATE,
+ CalendarContract.Events.RDATE,
),
null, null, null,
)?.use { c ->
@@ -1354,6 +1498,7 @@ class AndroidCalendarDataSource @Inject constructor(
allDay = c.getInt(4),
syncId = c.getString(5),
exdate = c.getString(6),
+ rdate = c.getString(7),
)
} else {
null
@@ -1369,7 +1514,16 @@ class AndroidCalendarDataSource @Inject constructor(
/** Null on a local calendar (and before a synced event's first push). */
val syncId: String? = null,
val exdate: String? = null,
+ val rdate: String? = null,
) {
+ /**
+ * Whether this row expands into more than itself. RDATE counts as much as
+ * RRULE: a sync adapter may list the occurrences instead of naming a rule,
+ * and the provider expands either — as does the read side, which marks
+ * such a row recurring too.
+ */
+ val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
+
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
beginMillis = beginMillis,
@@ -1560,6 +1714,12 @@ class AndroidCalendarDataSource @Inject constructor(
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
val row = querySeriesRow(eventId)
+ // Not a series, so the row *is* the event: an exclusion would put a
+ // recurrence set on a one-off, an exception would leave an orphan.
+ if (!row.recurs) {
+ deleteEvent(eventId)
+ return
+ }
if (row.syncId == null) {
// No _sync_id — a local calendar, or a synced event not pushed yet.
// A cancelled exception can only attach to its parent through
@@ -1573,6 +1733,7 @@ class AndroidCalendarDataSource @Inject constructor(
occurrenceMillis = beginMillis,
dtStartMillis = row.dtStartMillis,
rrule = row.rrule,
+ rdate = row.rdate,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt
index b47c83f..ac33e13 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt
@@ -88,9 +88,15 @@ interface CalendarRepository {
/**
* Change a single occurrence of a recurring event (exception row with the
- * form's values); returns the exception's `Events._ID`.
+ * form's values); returns the exception's `Events._ID`. [original] is what
+ * the row holds now, for the case where it turns out not to recur.
*/
- suspend fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
+ suspend fun updateOccurrence(
+ eventId: Long,
+ beginMillis: Long,
+ original: EventForm,
+ form: EventForm,
+ ): Long
/**
* Change a recurring event from [beginMillis] onwards (series split);
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
index f7b677a..e02bc1d 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar
+import android.util.Log
import de.jeanlucmakiola.floret.time.toEpochMillis
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
@@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicLong
import kotlin.time.Instant
@@ -210,26 +212,60 @@ class CalendarRepositoryImpl @Inject constructor(
}
override suspend fun exportEvents(calendarIds: Set?) =
- withContext(io) { dataSource.exportableEvents(calendarIds) }
+ withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) }
override suspend fun importEvents(
targetCalendarId: Long,
events: List,
): IcsImportSummary = withContext(io) {
val existing = dataSource.existingUids(targetCalendarId)
+ // Both are per-calendar, not per-event: looking them up once keeps a
+ // thousand-event restore to two extra queries. The palette is the
+ // uncurated one — an imported colour is matched against every key the
+ // account accepts, not the subset the picker shows.
+ val palette = dataSource.publishedEventColors(targetCalendarId)
+ val allDayMinutes = allDayReminderTimeMinutes()
var imported = 0
var skipped = 0
- for (event in events) {
+ var failed = 0
+ var notAttempted = 0
+ for ((index, event) in events.withIndex()) {
// A known UID means the event is already in this calendar — skip,
// keeping a restore idempotent (no overwrite this pass).
if (event.uid != null && event.uid in existing) {
skipped++
- } else {
- dataSource.insertImportedEvent(event, targetCalendarId)
+ continue
+ }
+ try {
+ dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette)
imported++
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: SecurityException) {
+ // Not this event's fault: the permission or the target calendar is
+ // gone, so every remaining insert would fail the same way.
+ Log.w(TAG, "Import stopped: the calendar is no longer writable", e)
+ // A known UID would have been skipped whatever happened, so it is
+ // no part of what a retry still has to import.
+ val remaining = events.drop(index)
+ val duplicates = remaining.count { it.uid?.let { uid -> uid in existing } == true }
+ skipped += duplicates
+ notAttempted = remaining.size - duplicates
+ break
+ } catch (e: Exception) {
+ // One event the provider won't take must not cost the user the
+ // rest of the file — foreign exports do carry rows it rejects
+ // outright (a malformed RRULE throws straight out of insert).
+ failed++
+ Log.w(TAG, "Skipped an unimportable event", e)
}
}
- IcsImportSummary(imported = imported, skippedDuplicate = skipped)
+ IcsImportSummary(
+ imported = imported,
+ skippedDuplicate = skipped,
+ failed = failed,
+ notAttempted = notAttempted,
+ )
}
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
@@ -262,9 +298,12 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun updateOccurrence(
eventId: Long,
beginMillis: Long,
+ original: EventForm,
form: EventForm,
): Long = withContext(io) {
- dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
+ dataSource.updateOccurrence(
+ eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
+ )
}
override suspend fun updateEventFromOccurrence(
@@ -288,6 +327,10 @@ class CalendarRepositoryImpl @Inject constructor(
) = withContext(io) {
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
}
+
+ private companion object {
+ const val TAG = "CalendarRepository"
+ }
}
private fun Flow.reQuery(block: suspend () -> T): Flow = flow {
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt
index f82a390..138da17 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt
@@ -3,17 +3,23 @@ package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
+import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
+import de.jeanlucmakiola.calendula.domain.EventStatus
+import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
+import de.jeanlucmakiola.calendula.domain.nearestTo
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
+import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.ResolverStyle
import java.time.LocalDate as JavaLocalDate
import java.time.LocalDateTime as JavaLocalDateTime
+import java.time.LocalTime as JavaLocalTime
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
internal data class EventWriteTimes(
@@ -261,6 +267,21 @@ internal fun buildOccurrenceExceptionValues(
putAll(eventColorColumns(form.colorKey, form.color))
}
+/**
+ * The form as a **detached occurrence**: the same edited values, with the
+ * series rule dropped so [buildEventInsertValues] writes a standalone one-off
+ * row (DTSTART + DTEND, no RRULE/DURATION) at the occurrence's own times.
+ *
+ * The "edit only this event" shape for a series with **no `_sync_id`**, where an
+ * exception row can't attach to its parent at all (Codeberg #234).
+ *
+ * The exception path gets the rule dropped for free — the provider clears the
+ * RRULE it cloned when an exception carries DTSTART + DURATION
+ * ([buildOccurrenceExceptionValues]). Here nothing is cloned, so it is stripped
+ * by hand; leaving it on would insert a second *series* overlapping the first.
+ */
+internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null)
+
/**
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
@@ -424,29 +445,92 @@ internal fun buildOccurrenceCancelValues(
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
*
+ * [rdate] travels with [rrule]: a sync adapter may list a series' occurrences
+ * instead of naming a rule, and rewriting the recurrence set without it would
+ * leave the provider nothing but DTSTART to re-expand from — the collapse this
+ * values map exists to avoid.
+ *
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat
- * of the same occurrence is folded away). All-day series take the `VALUE=DATE`
- * form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`).
+ * of the same occurrence is folded away — [mergedExdate]).
*/
internal fun buildOccurrenceExdateValues(
existingExdate: String?,
occurrenceMillis: Long,
dtStartMillis: Long,
rrule: String?,
+ rdate: String?,
duration: String?,
timezone: String?,
allDay: Int,
-): Map {
- val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
- val merged = (exdateStamps(existingExdate) + stamp).distinct().joinToString(",")
- return mapOf(
- CalendarContract.Events.EXDATE to merged,
- CalendarContract.Events.DTSTART to dtStartMillis,
- CalendarContract.Events.RRULE to rrule,
- CalendarContract.Events.DURATION to duration,
- CalendarContract.Events.EVENT_TIMEZONE to timezone,
- CalendarContract.Events.ALL_DAY to allDay,
- )
+): Map = mapOf(
+ CalendarContract.Events.EXDATE to
+ mergedExdate(existingExdate, occurrenceMillis, allDay != 0, timezone),
+ CalendarContract.Events.DTSTART to dtStartMillis,
+ CalendarContract.Events.RRULE to rrule,
+ CalendarContract.Events.RDATE to rdate,
+ CalendarContract.Events.DURATION to duration,
+ CalendarContract.Events.EVENT_TIMEZONE to timezone,
+ CalendarContract.Events.ALL_DAY to allDay,
+)
+
+/**
+ * [existingExdate] with the occurrence at [occurrenceMillis] excluded as well.
+ *
+ * Existing stamps are canonicalised first — all-day series take the `VALUE=DATE`
+ * form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`) — so
+ * appending to a list a sync adapter wrote in the other accepted form folds the
+ * repeat away instead of leaving the same day excluded twice in two spellings.
+ *
+ * A stamp in no form Calendula reads makes the whole list untouchable: it is kept
+ * exactly as it is, behind the parameter prefix that tells a reader how to
+ * resolve it, and the new exclusion joins it in the list's own form.
+ * Canonicalising the readable stamps around it would leave absolute stamps under
+ * a prefix claiming a zone for all of them — which is how the unreadable one
+ * quietly changes instant.
+ */
+private fun mergedExdate(
+ existingExdate: String?,
+ occurrenceMillis: Long,
+ isAllDay: Boolean,
+ timezone: String?,
+): String {
+ val listZone = exdateListZone(existingExdate, timezone)
+ val stamps = exdateStamps(existingExdate)
+ val canonical = stamps.map { canonicalExdateStamp(it, isAllDay, listZone) }
+ if (canonical.all { it != null }) {
+ val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
+ return (canonical.filterNotNull() + stamp).distinct().joinToString(",")
+ }
+ val prefix = existingExdate?.exdateParameterPrefix().orEmpty()
+ val stamp = if (isAllDay || prefix.isEmpty()) {
+ formatExdateStamp(occurrenceMillis, isAllDay)
+ } else {
+ Instant.ofEpochMilli(occurrenceMillis).atZone(listZone).format(FLOATING_EXDATE)
+ }
+ return prefix + (stamps + stamp).distinct().joinToString(",")
+}
+
+/**
+ * Whether [existingExdate] already excludes the occurrence at [occurrenceMillis]
+ * — i.e. it has already been dropped from the series, deleted or detached.
+ *
+ * Guards the detach path against running twice from a stale screen: the EXDATE
+ * merge folds the repeat away silently and the update still reports one row
+ * changed, so a second save would leave a second standalone copy.
+ */
+internal fun exdateContains(
+ existingExdate: String?,
+ occurrenceMillis: Long,
+ isAllDay: Boolean,
+ timezone: String?,
+): Boolean {
+ val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
+ val listZone = exdateListZone(existingExdate, timezone)
+ // Canonicalised rather than compared verbatim: a sync adapter may spell the
+ // same exclusion differently to Calendula, and a guard that misses it lets
+ // the detach run a second time and leave a second standalone copy.
+ return exdateStamps(existingExdate)
+ .any { canonicalExdateStamp(it, isAllDay, listZone) == stamp }
}
/**
@@ -463,10 +547,11 @@ internal fun buildOccurrenceExdateValues(
* [original] and written from [updated], so the `VALUE=DATE` and date-time forms
* convert into each other when the event switches.
*
- * Null when there is nothing to carry: no stamps, or a stamp in a form Calendula
- * never writes (a `TZID=`-parameterised or floating one from a sync adapter). Those
- * are left exactly as they are rather than guessed at — a stale stamp excludes
- * nothing, but a mangled one could exclude the wrong occurrence.
+ * A zoned list is read in the zone its prefix names ([exdateListZone]). Null
+ * when there is nothing to carry: no stamps, or a stamp in a form Calendula
+ * cannot read at all. Those are left exactly as they are rather than guessed at —
+ * a stale stamp excludes nothing, but a mangled one could exclude the wrong
+ * occurrence.
*/
internal fun shiftedExdate(
existingExdate: String?,
@@ -478,10 +563,12 @@ internal fun shiftedExdate(
if (stamps.isEmpty()) return null
val fromZone = original.writeZone(zone)
val toZone = updated.writeZone(zone)
+ val listZone = exdateListZone(existingExdate, fromZone.id)
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
return stamps
.map { stamp ->
- val local = parseExdateStamp(stamp, original.isAllDay, fromZone) ?: return null
+ val local = parseExdateStamp(stamp, original.isAllDay, fromZone, listZone)
+ ?: return null
formatExdateStamp(local.plus(wallClockShift), updated.isAllDay, toZone)
}
.distinct()
@@ -498,27 +585,97 @@ internal fun shiftedExdate(
* is editing, so it exists by definition, and honouring a stale exclusion for it
* would swallow the edit whole.
*
+ * The stamps that qualify come back canonicalised, since the zone prefix that
+ * told the reader how to resolve them does not survive the filter.
+ *
* Null when nothing qualifies, or when a stamp can't be read (see [shiftedExdate]).
*/
-internal fun exdateAfter(existingExdate: String?, beginMillis: Long, isAllDay: Boolean): String? {
+internal fun exdateAfter(
+ existingExdate: String?,
+ beginMillis: Long,
+ isAllDay: Boolean,
+ timezone: String?,
+): String? {
val stamps = exdateStamps(existingExdate)
if (stamps.isEmpty()) return null
+ val listZone = exdateListZone(existingExdate, timezone)
return stamps
- .filter { stamp ->
- val utc = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC) ?: return null
- utc.toInstant(ZoneOffset.UTC).toEpochMilli() > beginMillis
+ .mapNotNull { stamp ->
+ val utc = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC, listZone) ?: return null
+ utc.takeIf { it.toInstant(ZoneOffset.UTC).toEpochMilli() > beginMillis }
}
+ .map { formatExdateStamp(it, isAllDay, ZoneOffset.UTC) }
.takeIf { it.isNotEmpty() }
?.joinToString(",")
}
-/** The individual stamps of an EXDATE column value; it is a comma-separated list. */
+/**
+ * The individual stamps of an EXDATE column value; it is a comma-separated list,
+ * optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of
+ * a zoned one ([exdateListZone] reads that half).
+ */
private fun exdateStamps(exdate: String?): List = exdate
+ ?.withoutExdateParameters()
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
+/**
+ * An EXDATE column value with the parameter section its stamps sit behind taken
+ * off.
+ */
+internal fun String.withoutExdateParameters(): String = substring(exdateParameterPrefix().length)
+
+/**
+ * The parameter section an EXDATE list's stamps sit behind, its `;` included, or
+ * empty when it has none. Only a `;` before the first stamp separator is one —
+ * anything later belongs to a stamp, and cutting there would drop every stamp in
+ * front of it.
+ */
+private fun String.exdateParameterPrefix(): String {
+ val semicolon = indexOf(';')
+ val comma = indexOf(',')
+ return if (semicolon >= 0 && (comma < 0 || semicolon < comma)) {
+ substring(0, semicolon + 1)
+ } else {
+ ""
+ }
+}
+
+/**
+ * The zone the floating stamps of [exdate] are wall clock in: the one its
+ * parameter prefix names, or [fallback] — the event's own `EVENT_TIMEZONE` —
+ * when there is no prefix, or the device's tz database doesn't know the name it
+ * uses (Exchange spells zones its own way). The series' own zone is the right
+ * guess, since a zoned exclusion list is written in it. UTC last, for a row that
+ * carries no zone either. Stamps already written as UTC instants ignore all this.
+ *
+ * Both spellings of the prefix are read. AOSP's `RecurrenceSet` stores the TZID
+ * parameter's *value* alone (`Europe/Berlin;`), and so does DAVx5; the
+ * iCalendar `TZID=;` some writers keep is the other one. Only matching the
+ * second would miss every list the provider itself wrote.
+ */
+internal fun exdateListZone(exdate: String?, fallback: String?): ZoneId =
+ exdate?.exdateZoneName()?.toZoneIdOrNull()
+ ?: fallback?.toZoneIdOrNull()
+ ?: ZoneOffset.UTC
+
+/** The zone id an EXDATE list's parameter prefix names, with or without `TZID=`. */
+private fun String.exdateZoneName(): String {
+ val prefix = exdateParameterPrefix().dropLast(1)
+ return if (prefix.startsWith(TZID_PREFIX, ignoreCase = true)) {
+ prefix.drop(TZID_PREFIX.length)
+ } else {
+ prefix
+ }
+}
+
+private fun String.toZoneIdOrNull(): ZoneId? = runCatching { ZoneId.of(this) }.getOrNull()
+
+/** The optional `TZID=` a zoned EXDATE list's zone id may sit behind. */
+private const val TZID_PREFIX = "TZID="
+
/**
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
@@ -545,18 +702,66 @@ private fun formatExdateStamp(local: JavaLocalDateTime, isAllDay: Boolean, zone:
/**
* [stamp] as the wall clock it names in [zone] — the inverse of
- * [formatExdateStamp]. Null for anything but the two forms Calendula writes, so a
- * caller can tell "not ours, leave it alone" from a value it may safely re-time.
+ * [formatExdateStamp]. Null for anything Calendula cannot read, so a caller can
+ * tell "not ours, leave it alone" from a value it may safely re-time.
+ *
+ * A timed exclusion is read both as the UTC instant Calendula writes and as a
+ * floating stamp, which [listZone] then resolves. An all-day one is read both
+ * bare (`yyyyMMdd`, what Calendula writes) and padded to a midnight stamp, in
+ * either spelling: sync adapters are inconsistent about which they store — the
+ * same forms [exportExDates] reshapes on the way out — and refusing one would
+ * drop every exclusion of a foreign all-day series the moment it is re-timed.
*/
-private fun parseExdateStamp(stamp: String, isAllDay: Boolean, zone: ZoneId): JavaLocalDateTime? =
- runCatching {
- if (isAllDay) {
- JavaLocalDate.parse(stamp, ALL_DAY_EXDATE).atStartOfDay()
- } else {
- JavaLocalDateTime.parse(stamp, TIMED_EXDATE)
- .atZone(ZoneOffset.UTC).withZoneSameInstant(zone).toLocalDateTime()
- }
- }.getOrNull()
+private fun parseExdateStamp(
+ stamp: String,
+ isAllDay: Boolean,
+ zone: ZoneId,
+ listZone: ZoneId,
+): JavaLocalDateTime? =
+ if (isAllDay) {
+ parseAllDayExdate(stamp)?.atStartOfDay()
+ } else {
+ parseTimedExdate(stamp, listZone)?.withZoneSameInstant(zone)?.toLocalDateTime()
+ }
+
+/**
+ * A timed EXDATE stamp as the instant it names: the trailing `Z` form resolved in
+ * UTC, or the floating one resolved in [listZone].
+ */
+private fun parseTimedExdate(stamp: String, listZone: ZoneId): ZonedDateTime? =
+ runCatching { JavaLocalDateTime.parse(stamp, TIMED_EXDATE).atZone(ZoneOffset.UTC) }.getOrNull()
+ ?: runCatching {
+ JavaLocalDateTime.parse(stamp, FLOATING_EXDATE).atZone(listZone)
+ }.getOrNull()
+
+/**
+ * The calendar day an all-day EXDATE stamp names, bare or padded to a midnight
+ * stamp. A padded stamp naming any other time of day is refused rather than
+ * rounded: it is not a form an all-day series is written in, so it could be
+ * naming something else entirely. Midnight is midnight in any zone, so the
+ * padding's own spelling — trailing `Z` or floating — makes no difference.
+ */
+private fun parseAllDayExdate(stamp: String): JavaLocalDate? {
+ runCatching { JavaLocalDate.parse(stamp, ALL_DAY_EXDATE) }.getOrNull()?.let { return it }
+ val padded = parsePaddedExdate(stamp) ?: return null
+ return padded.toLocalDate().takeIf { padded.toLocalTime() == JavaLocalTime.MIDNIGHT }
+}
+
+/** An `uuuuMMddTHHmmss` stamp, with or without the trailing `Z`, as its own wall clock. */
+private fun parsePaddedExdate(stamp: String): JavaLocalDateTime? =
+ runCatching { JavaLocalDateTime.parse(stamp, TIMED_EXDATE) }.getOrNull()
+ ?: runCatching { JavaLocalDateTime.parse(stamp, FLOATING_EXDATE) }.getOrNull()
+
+/**
+ * [stamp] in the form Calendula writes, so two spellings of the same exclusion
+ * compare equal. Null for a stamp in no form it reads.
+ */
+internal fun canonicalExdateStamp(
+ stamp: String,
+ isAllDay: Boolean,
+ listZone: ZoneId,
+): String? = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC, listZone)
+ ?.let { formatExdateStamp(it, isAllDay, ZoneOffset.UTC) }
/** `VALUE=DATE` EXDATE form, for an all-day series. */
private val ALL_DAY_EXDATE: DateTimeFormatter =
@@ -566,6 +771,10 @@ private val ALL_DAY_EXDATE: DateTimeFormatter =
private val TIMED_EXDATE: DateTimeFormatter =
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss'Z'").withResolverStyle(ResolverStyle.STRICT)
+/** The same form without the `Z`, wall clock in the list's zone rather than UTC. */
+private val FLOATING_EXDATE: DateTimeFormatter =
+ DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss").withResolverStyle(ResolverStyle.STRICT)
+
/**
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
@@ -613,3 +822,73 @@ internal fun AccessLevel.toProviderValue(): Int = when (this) {
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
}
+
+/**
+ * The `Events` row for a `.ics` event being imported into [calendarId].
+ *
+ * A recurring row carries RRULE + DURATION (and any EXDATE) with no DTEND; a
+ * one-off carries DTEND. All-day rows live at UTC midnights, exactly as the
+ * parser hands them over.
+ *
+ * [palette] is the target account's published event colours. A calendar that
+ * publishes one rejects a raw `EVENT_COLOR`, so an imported colour — which is
+ * an arbitrary ARGB from a foreign app — is snapped to the nearest key it does
+ * accept; accounts with no palette take the raw value (see [eventColorColumns]).
+ */
+internal fun buildImportedEventValues(
+ event: ParsedIcsEvent,
+ calendarId: Long,
+ uid: String,
+ palette: List,
+): Map = buildMap {
+ val startMillis = event.start.toEpochMilliseconds()
+ val endMillis = event.end.toEpochMilliseconds()
+ put(CalendarContract.Events.CALENDAR_ID, calendarId)
+ put(CalendarContract.Events.UID_2445, uid)
+ put(CalendarContract.Events.TITLE, event.summary.trim())
+ put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
+ put(CalendarContract.Events.DTSTART, startMillis)
+ if (event.recurrenceRule == null) {
+ put(CalendarContract.Events.DTEND, endMillis)
+ } else {
+ put(CalendarContract.Events.RRULE, event.recurrenceRule)
+ put(
+ CalendarContract.Events.DURATION,
+ importDuration(startMillis, endMillis, event.isAllDay),
+ )
+ event.exDates.takeIf { it.isNotEmpty() }
+ ?.let { put(CalendarContract.Events.EXDATE, it.joinToString(",")) }
+ }
+ // All-day rows live at UTC midnights (the file already encodes them so);
+ // timed rows keep the event's own zone.
+ put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
+ put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
+ put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
+ event.location?.trim()?.takeIf { it.isNotEmpty() }
+ ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
+ event.description?.trim()?.takeIf { it.isNotEmpty() }
+ ?.let { put(CalendarContract.Events.DESCRIPTION, it) }
+ event.color?.let { color ->
+ val key = palette.nearestTo(color)?.key
+ putAll(eventColorColumns(colorKey = key, color = if (key == null) color else null))
+ }
+}
+
+/**
+ * Provider `DURATION` for an imported recurring row: whole days for an all-day
+ * series, seconds otherwise. An all-day series is never shorter than a day —
+ * the provider expands a zero-length one into no instances at all, so the
+ * series would vanish (which is what a literal read of the Fossify family's
+ * inclusive DTEND used to produce; see "Importing foreign .ics" in
+ * docs/ARCHITECTURE.md).
+ */
+private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
+ val span = (endMillis - startMillis).coerceAtLeast(0)
+ return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S"
+}
+
+private fun EventStatus.toProviderStatus(): Int = when (this) {
+ EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
+ EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
+ EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt
index ce8befa..d20dad8 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt
@@ -5,6 +5,9 @@ import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
+import java.time.Instant
+import java.time.ZoneId
+import java.time.ZoneOffset
/**
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
@@ -12,14 +15,23 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
* [calendarName] the display name of its calendar (emitted as
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
* MapColumnReader.
+ *
+ * An all-day row's raw offset has the firing time of day encoded into it and is
+ * normally *negative* (see [AllDayReminderEncoding]); written out literally it
+ * would be dropped as a trigger that fires after the event, losing the reminder.
+ * [allDayReminderTimeMinutes] and [zone] decode it back to the whole-day lead
+ * time the file should carry, which an import re-encodes for its own device.
*/
internal fun ColumnReader.toIcsEvent(
reminderMinutes: List,
calendarName: String?,
+ allDayReminderTimeMinutes: Int,
+ zone: ZoneId = ZoneId.systemDefault(),
): IcsEvent {
val eventId = getLong(EventExportProjection.IDX_ID)
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
+ val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
// so the writer can render DTEND. A missing/blank both means a zero-length event.
@@ -40,16 +52,77 @@ internal fun ColumnReader.toIcsEvent(
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
start = dtStart.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
- isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
+ isAllDay = isAllDay,
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
?: "UTC",
recurrenceRule = rrule,
+ exDates = if (rrule == null) {
+ emptyList()
+ } else {
+ exportExDates(
+ getString(EventExportProjection.IDX_EXDATE),
+ isAllDay,
+ getString(EventExportProjection.IDX_EVENT_TIMEZONE),
+ )
+ },
location = getString(EventExportProjection.IDX_LOCATION),
description = getString(EventExportProjection.IDX_DESCRIPTION),
- reminderMinutes = reminderMinutes,
+ reminderMinutes = if (isAllDay) {
+ val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
+ reminderMinutes.map {
+ fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
+ }
+ } else {
+ reminderMinutes
+ },
status = status,
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
calendarName = calendarName,
)
}
+/**
+ * The row's `EXDATE` as the writer wants it: one stamp per entry, empties
+ * dropped.
+ *
+ * An all-day exclusion names a calendar day, and sync adapters are inconsistent
+ * about whether they write it as a bare `yyyyMMdd` or pad it to a midnight
+ * stamp; the time part is dropped so the exported value is the day either way.
+ * Calendula's own writes ([buildOccurrenceExdateValues]) are already bare.
+ *
+ * A zoned exclusion arrives in AOSP's `RecurrenceSet` form, `;`,
+ * where the prefix names the zone the whole list is wall clock in. [IcsWriter]
+ * emits one bare `EXDATE:` line, and a parameter belongs before that colon, not
+ * inside the value — passed on as-is it would make the line unparseable. The
+ * prefix is therefore taken off and its stamps resolved to UTC instants
+ * ([canonicalExdateStamp], the same reading the write path does), the form every
+ * reader accepts.
+ *
+ * Which zone that resolution uses is [exdateListZone]'s call, the same one the
+ * write path makes: the `TZID=` when this device's tz database knows the name,
+ * else the row's own [eventTimezone]. A stamp is never emitted floating just
+ * because the prefix named a zone we couldn't place — against a `DTSTART;TZID=…`
+ * line every reader would resolve it in its own zone and the exclusion would stop
+ * matching its occurrence.
+ */
+internal fun exportExDates(
+ exdate: String?,
+ isAllDay: Boolean,
+ eventTimezone: String? = null,
+): List {
+ if (exdate.isNullOrBlank()) return emptyList()
+ val zone = exdateListZone(exdate, eventTimezone)
+ return exdate
+ .withoutExdateParameters()
+ .split(',')
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .map { stamp ->
+ if (isAllDay) {
+ stamp.substringBefore('T')
+ } else {
+ canonicalExdateStamp(stamp, isAllDay = false, listZone = zone) ?: stamp
+ }
+ }
+ .distinct()
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt
index 264c5ac..b535aee 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt
@@ -121,10 +121,12 @@ internal object EventDetailProjection {
/**
* Master/one-off Events rows for a whole-calendar backup. Unlike
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
- * backups) and `DURATION` (recurring rows carry it instead of DTEND). Modified-
+ * backups) and `DURATION` (recurring rows carry it instead of DTEND), plus
+ * `EXDATE` so a series exports the occurrences deleted from it. Modified-
* occurrence and cancelled-exception rows are filtered out by the query
- * (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't
- * exported yet — a documented v1 limit (import skips them too).
+ * (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides aren't exported — a
+ * documented v1 limit (import skips them too). EXDATE is unaffected by that
+ * filter: it lives on the master row, not on an exception of its own.
*/
internal object EventExportProjection {
val COLUMNS: Array = arrayOf(
@@ -137,6 +139,7 @@ internal object EventExportProjection {
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.RRULE,
+ CalendarContract.Events.EXDATE,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.DESCRIPTION,
CalendarContract.Events.STATUS,
@@ -153,11 +156,12 @@ internal object EventExportProjection {
const val IDX_ALL_DAY = 6
const val IDX_EVENT_TIMEZONE = 7
const val IDX_RRULE = 8
- const val IDX_LOCATION = 9
- const val IDX_DESCRIPTION = 10
- const val IDX_STATUS = 11
- const val IDX_AVAILABILITY = 12
- const val IDX_CALENDAR_ID = 13
+ const val IDX_EXDATE = 9
+ const val IDX_LOCATION = 10
+ const val IDX_DESCRIPTION = 11
+ const val IDX_STATUS = 12
+ const val IDX_AVAILABILITY = 13
+ const val IDX_CALENDAR_ID = 14
}
/**
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ProviderReminderMinutes.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ProviderReminderMinutes.kt
new file mode 100644
index 0000000..33fcafd
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ProviderReminderMinutes.kt
@@ -0,0 +1,24 @@
+package de.jeanlucmakiola.calendula.data.calendar
+
+import android.provider.CalendarContract
+
+/**
+ * "Fire at whatever lead time this account defaults to" — a sentinel in the
+ * `Reminders.MINUTES` column, not an offset, so nothing may do arithmetic on it.
+ * `CalendarContract` never exposes what an account resolves it to, so Calendula
+ * neither fires such a row ([withoutProviderDefaults]) nor decodes it
+ * ([fromProviderAllDayMinutes]); the detail screen names it instead.
+ *
+ * It is the **only** negative that means this. A real all-day offset is often
+ * negative too — the firing time of day is encoded into it — so a `< 0` test
+ * would silence reminders that do fire.
+ */
+internal const val MINUTES_DEFAULT = CalendarContract.Reminders.MINUTES_DEFAULT
+
+/**
+ * Raw provider offsets with the account-default rows dropped, so the pure
+ * planner ([de.jeanlucmakiola.calendula.domain.reminders.planReminders]) only
+ * ever sees offsets it can resolve.
+ */
+internal fun Map>.withoutProviderDefaults(): Map> =
+ mapValues { (_, minutes) -> minutes.filter { it != MINUTES_DEFAULT } }
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt
index a4f447f..3c89ca9 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt
@@ -7,6 +7,7 @@ import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
+import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
@@ -90,7 +91,8 @@ class ReminderScanner @Inject constructor(
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
val planned = planReminders(
instances = occurrences,
- minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }),
+ minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId })
+ .withoutProviderDefaults(),
zone = ZoneId.systemDefault(),
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt
index 9b44fde..8365639 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt
@@ -110,3 +110,17 @@ private const val CURATION_TRIGGER_SIZE = 36
* apart that two swatches never read as the same colour in the grid.
*/
private const val MIN_DISTANCE = 0.025f
+
+/**
+ * The palette entry closest to [argb], or null when the account publishes no
+ * palette (its calendars then take a raw `EVENT_COLOR` instead).
+ *
+ * Used to land a colour that came from outside the account — an imported `.ics`
+ * carries an arbitrary ARGB, while a palette calendar only accepts one of its
+ * own keys. Distance is measured in Oklab, so the match is the one that looks
+ * closest rather than the one that is closest in RGB coordinates.
+ */
+fun List.nearestTo(argb: Int): EventColorOption? {
+ val target = oklchOf(argb)
+ return minByOrNull { oklchOf(it.argb).distanceTo(target) }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsColor.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsColor.kt
new file mode 100644
index 0000000..d6ddc63
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsColor.kt
@@ -0,0 +1,80 @@
+package de.jeanlucmakiola.calendula.domain.ics
+
+/** Opaque alpha, forced onto any colour that arrives without one. */
+private const val OPAQUE_ALPHA = 0xFF000000.toInt()
+
+/**
+ * Resolve a colour value out of a foreign `.ics` to an opaque ARGB int.
+ *
+ * Three encodings are in the wild and all appear in the files Calendula is
+ * asked to import:
+ * - a CSS3 colour name, which is what RFC 7986 `COLOR` is defined as
+ * (`COLOR:tomato`);
+ * - a raw signed Android colour int, which the Simple Calendar / Fossify
+ * family writes in its `X-…-COLOR` extensions (`X-FOSSIFY-EVENT-COLOR:-2818048`);
+ * - `#rrggbb` / `#aarrggbb` hex, used by assorted other producers.
+ *
+ * Anything else — including the literal `null` Fossify emits when its calendar
+ * lookup misses — resolves to null.
+ */
+fun parseIcsColorValue(raw: String?): Int? {
+ val value = raw?.trim().orEmpty()
+ if (value.isEmpty()) return null
+
+ if (value.startsWith("#")) {
+ val hex = value.substring(1)
+ val parsed = hex.toLongOrNull(16)?.toInt() ?: return null
+ return when (hex.length) {
+ 6 -> parsed or OPAQUE_ALPHA
+ 8 -> parsed
+ else -> null
+ }
+ }
+ value.toIntOrNull()?.let { return if (it ushr 24 == 0) it or OPAQUE_ALPHA else it }
+ return cssColors[value.lowercase()]
+}
+
+/** The CSS3 extended colour keywords, the value space of RFC 7986 `COLOR`. */
+private val cssColors: Map by lazy {
+ CSS3_TABLE.split(' ').associate { entry ->
+ val name = entry.substringBefore('=')
+ name to (entry.substringAfter('=').toInt(16) or OPAQUE_ALPHA)
+ }
+}
+
+private const val CSS3_TABLE =
+ "aliceblue=f0f8ff antiquewhite=faebd7 aqua=00ffff aquamarine=7fffd4 " +
+ "azure=f0ffff beige=f5f5dc bisque=ffe4c4 black=000000 blanchedalmond=ffebcd " +
+ "blue=0000ff blueviolet=8a2be2 brown=a52a2a burlywood=deb887 cadetblue=5f9ea0 " +
+ "chartreuse=7fff00 chocolate=d2691e coral=ff7f50 cornflowerblue=6495ed " +
+ "cornsilk=fff8dc crimson=dc143c cyan=00ffff darkblue=00008b darkcyan=008b8b " +
+ "darkgoldenrod=b8860b darkgray=a9a9a9 darkgreen=006400 darkgrey=a9a9a9 " +
+ "darkkhaki=bdb76b darkmagenta=8b008b darkolivegreen=556b2f darkorange=ff8c00 " +
+ "darkorchid=9932cc darkred=8b0000 darksalmon=e9967a darkseagreen=8fbc8f " +
+ "darkslateblue=483d8b darkslategray=2f4f4f darkslategrey=2f4f4f darkturquoise=00ced1 " +
+ "darkviolet=9400d3 deeppink=ff1493 deepskyblue=00bfff dimgray=696969 " +
+ "dimgrey=696969 dodgerblue=1e90ff firebrick=b22222 floralwhite=fffaf0 " +
+ "forestgreen=228b22 fuchsia=ff00ff gainsboro=dcdcdc ghostwhite=f8f8ff " +
+ "gold=ffd700 goldenrod=daa520 gray=808080 green=008000 greenyellow=adff2f " +
+ "grey=808080 honeydew=f0fff0 hotpink=ff69b4 indianred=cd5c5c indigo=4b0082 " +
+ "ivory=fffff0 khaki=f0e68c lavender=e6e6fa lavenderblush=fff0f5 lawngreen=7cfc00 " +
+ "lemonchiffon=fffacd lightblue=add8e6 lightcoral=f08080 lightcyan=e0ffff " +
+ "lightgoldenrodyellow=fafad2 lightgray=d3d3d3 lightgreen=90ee90 lightgrey=d3d3d3 " +
+ "lightpink=ffb6c1 lightsalmon=ffa07a lightseagreen=20b2aa lightskyblue=87cefa " +
+ "lightslategray=778899 lightslategrey=778899 lightsteelblue=b0c4de " +
+ "lightyellow=ffffe0 lime=00ff00 limegreen=32cd32 linen=faf0e6 magenta=ff00ff " +
+ "maroon=800000 mediumaquamarine=66cdaa mediumblue=0000cd mediumorchid=ba55d3 " +
+ "mediumpurple=9370db mediumseagreen=3cb371 mediumslateblue=7b68ee " +
+ "mediumspringgreen=00fa9a mediumturquoise=48d1cc mediumvioletred=c71585 " +
+ "midnightblue=191970 mintcream=f5fffa mistyrose=ffe4e1 moccasin=ffe4b5 " +
+ "navajowhite=ffdead navy=000080 oldlace=fdf5e6 olive=808000 olivedrab=6b8e23 " +
+ "orange=ffa500 orangered=ff4500 orchid=da70d6 palegoldenrod=eee8aa " +
+ "palegreen=98fb98 paleturquoise=afeeee palevioletred=db7093 papayawhip=ffefd5 " +
+ "peachpuff=ffdab9 peru=cd853f pink=ffc0cb plum=dda0dd powderblue=b0e0e6 " +
+ "purple=800080 rebeccapurple=663399 red=ff0000 rosybrown=bc8f8f royalblue=4169e1 " +
+ "saddlebrown=8b4513 salmon=fa8072 sandybrown=f4a460 seagreen=2e8b57 " +
+ "seashell=fff5ee sienna=a0522d silver=c0c0c0 skyblue=87ceeb slateblue=6a5acd " +
+ "slategray=708090 slategrey=708090 snow=fffafa springgreen=00ff7f " +
+ "steelblue=4682b4 tan=d2b48c teal=008080 thistle=d8bfd8 tomato=ff6347 " +
+ "turquoise=40e0d0 violet=ee82ee wheat=f5deb3 white=ffffff whitesmoke=f5f5f5 " +
+ "yellow=ffff00 yellowgreen=9acd32"
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt
index 6e82538..19da17b 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt
@@ -22,6 +22,12 @@ data class IcsEvent(
val zoneId: String,
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
val recurrenceRule: String? = null,
+ /**
+ * Occurrences deleted from the series, in the provider's `EXDATE` shape —
+ * `yyyyMMdd` for an all-day series, a UTC `yyyyMMddTHHmmssZ` stamp
+ * otherwise. Empty for a one-off event.
+ */
+ val exDates: List = emptyList(),
val location: String? = null,
val description: String? = null,
/** Reminder lead times in minutes before start (raw provider offsets). */
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt
index dc7e7e3..9c5693a 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt
@@ -7,14 +7,25 @@ import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
+import kotlinx.datetime.number
import kotlinx.datetime.toInstant
+import kotlinx.datetime.toLocalDateTime
+import kotlin.math.ceil
import kotlin.time.Instant
+/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */
+private const val DAY_MILLIS = 86_400_000L
+private const val MINUTES_PER_DAY = 1_440
+
/**
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
* but [uid] is nullable (an incoming event may carry none; the insert layer
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
* how the writer encoded them.
+ *
+ * [reminderMinutes] holds raw lead times as the file's `VALARM`s expressed
+ * them — minutes *before* start, so a negative entry means "after start".
+ * [semanticReminderMinutes] turns those into the lead times Calendula models.
*/
data class ParsedIcsEvent(
val uid: String?,
@@ -30,8 +41,45 @@ data class ParsedIcsEvent(
val status: EventStatus = EventStatus.Confirmed,
val availability: Availability = Availability.Busy,
val calendarName: String? = null,
+ /** Opaque ARGB the source file gave this event, if any (see [parseIcsColorValue]). */
+ val color: Int? = null,
+ /** Excluded occurrences, already in the provider's `EXDATE` shape. */
+ val exDates: List = emptyList(),
+ /** True when this came from a `VTODO` rather than a `VEVENT`. */
+ val isTask: Boolean = false,
)
+/**
+ * [reminderMinutes] mapped onto Calendula's model: a lead time in minutes before
+ * the event, never negative.
+ *
+ * For a timed event the raw lead time already is that, and a trigger that fires
+ * *after* the start models no lead time at all — it is dropped rather than
+ * clamped, which would invent a reminder at the start the file never asked for
+ * (a legal `TRIGGER:PT30M` follow-up alarm is the case). All-day reminders are
+ * whole days before the event, fired at the user's configured time of day (see
+ * `AllDayReminderEncoding`), so a raw offset counts the whole days its trigger
+ * lands *earlier than* the event's UTC midnight — i.e. it rounds **up**.
+ *
+ * Rounding to the nearest day instead would lose a day for every producer whose
+ * all-day notifications fire after noon: a file's raw offset is
+ * `days × 1440 − timeOfDay`, so "1 day before at 18:00" arrives as
+ * `TRIGGER:-PT6H` (raw 360) and would read as "on the day". Rounding up also
+ * lands Fossify's encoding correctly — it writes "on the day at 09:00" as a
+ * *positive* `TRIGGER:P0DT9H0M0S`, nine hours after the UTC midnight, which
+ * ceils to zero days before.
+ */
+fun ParsedIcsEvent.semanticReminderMinutes(): List = reminderMinutes
+ .mapNotNull { raw ->
+ if (isAllDay) {
+ ceil(raw.toDouble() / MINUTES_PER_DAY).toInt().coerceAtLeast(0) * MINUTES_PER_DAY
+ } else {
+ raw.takeIf { it >= 0 }
+ }
+ }
+ .distinct()
+ .sorted()
+
/** Things the parser dropped rather than failing — surfaced in the import report. */
enum class IcsParseWarning {
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
@@ -45,6 +93,12 @@ enum class IcsParseWarning {
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
UnknownTimezone,
+
+ /** `VTODO` components were imported as events — Calendula has no task model. */
+ TasksImportedAsEvents,
+
+ /** A malformed `RRULE` was repaired or dropped (see [sanitizeRrule]). */
+ RecurrenceRuleRepaired,
}
data class IcsParseResult(
@@ -52,8 +106,28 @@ data class IcsParseResult(
val warnings: Set,
)
-/** Outcome of a bulk `.ics` import into one calendar. */
-data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
+/**
+ * Outcome of a bulk `.ics` import into one calendar. [failed] counts events the
+ * provider rejected — the import continues past them, so a single unusable
+ * event can't cost the user the rest of the file.
+ *
+ * [notAttempted] counts what a retry still has to import, because the permission
+ * or the target calendar went away mid-file and every remaining insert would have
+ * failed the same way. Events past that point whose UID is already in the
+ * calendar count under [skippedDuplicate] instead — they were never going to be
+ * added either way. It travels in the summary rather than as an exception so the
+ * counts of what *did* land survive: an import is only idempotent for events that
+ * carry a UID, so a retry told "nothing happened" would duplicate the rest.
+ */
+data class IcsImportSummary(
+ val imported: Int,
+ val skippedDuplicate: Int,
+ val failed: Int = 0,
+ val notAttempted: Int = 0,
+)
+
+/** The non-standard property most exporters name the whole calendar with. */
+private const val CALENDAR_NAME_PROPERTY = "X-WR-CALNAME"
/**
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
@@ -62,6 +136,9 @@ data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
* resolved against the OS tz database instead ([deviceZone] is the fallback).
+ *
+ * `VTODO` components are read as all-day/timed events: Calendula models no
+ * tasks, and dropping them would silently lose half of some exports.
*/
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
@@ -69,42 +146,57 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
val lines = unfoldLines(text)
val events = mutableListOf()
val warnings = mutableSetOf()
- var calendarName: String? = null
+ // Scanned up front, not in document order: an X-WR-CALNAME after the
+ // first VEVENT still names the calendar. The cheap prefix test first,
+ // so a file without one doesn't pay a full parse to find nothing.
+ val calendarName = lines.asSequence()
+ .filter { it.startsWith(CALENDAR_NAME_PROPERTY, ignoreCase = true) }
+ .mapNotNull(::parseContentLine)
+ .firstOrNull { it.name == CALENDAR_NAME_PROPERTY }
+ ?.let { unescapeText(it.value).trim().ifEmpty { null } }
var i = 0
while (i < lines.size) {
val line = parseContentLine(lines[i])
if (line == null) { i++; continue }
+ val component = when {
+ line.isBegin("VEVENT") -> "VEVENT"
+ line.isBegin("VTODO") -> "VTODO"
+ else -> null
+ }
when {
- line.isBegin("VEVENT") -> {
- val end = indexOfEnd(lines, i + 1, "VEVENT")
- parseVevent(lines.subList(i + 1, end), calendarName, warnings)
- ?.let(events::add)
+ component != null -> {
+ val end = indexOfEnd(lines, i + 1, component)
+ parseComponent(
+ body = lines.subList(i + 1, end),
+ fileCalendarName = calendarName,
+ warnings = warnings,
+ isTask = component == "VTODO",
+ )?.let(events::add)
i = end + 1
}
line.isBegin("VTIMEZONE") -> {
// Skipped wholesale; TZIDs resolve against the OS tz database.
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
}
- line.name == "X-WR-CALNAME" -> {
- calendarName = unescapeText(line.value).trim().ifEmpty { null }
- i++
- }
else -> i++
}
}
+ if (events.any { it.isTask }) warnings.add(IcsParseWarning.TasksImportedAsEvents)
return IcsParseResult(events, warnings)
}
- private fun parseVevent(
+ private fun parseComponent(
body: List,
fileCalendarName: String?,
warnings: MutableSet,
+ isTask: Boolean,
): ParsedIcsEvent? {
var uid: String? = null
var summary = ""
var dtStart: IcsDateTime? = null
var dtEnd: IcsDateTime? = null
+ var due: IcsDateTime? = null
var duration: String? = null
var rrule: String? = null
var location: String? = null
@@ -112,7 +204,11 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
var status = EventStatus.Confirmed
var availability = Availability.Busy
var calendarName = fileCalendarName
+ var category: String? = null
+ var eventColor: Int? = null
+ var calendarColor: Int? = null
val reminders = mutableListOf()
+ val exDateLines = mutableListOf()
var skipAsOverride = false
var i = 0
@@ -129,9 +225,11 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
"UID" -> uid = line.value.trim().ifEmpty { null }
"SUMMARY" -> summary = unescapeText(line.value)
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
+ "DUE" -> if (isTask) due = parseIcsDateTime(line, warnings)
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
"DURATION" -> duration = line.value.trim()
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
+ "EXDATE" -> exDateLines.add(line)
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
"STATUS" -> status = mapIcsStatus(line.value)
@@ -142,6 +240,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
"X-CALENDULA-CALENDAR" ->
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
+ // Fossify names the source calendar per event; it never writes
+ // an X-WR-CALNAME, so this is the only calendar label its files
+ // carry. Elsewhere CATEGORIES is a plain tag list, so it only
+ // stands in where nothing named the calendar outright.
+ "CATEGORIES" -> category = singleCategory(line.value) ?: category
+ // Per-event colour, most specific encoding first (see parseIcsColorValue).
+ "X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" ->
+ eventColor = parseIcsColorValue(line.value) ?: eventColor
+ "COLOR" -> eventColor = eventColor ?: parseIcsColorValue(line.value)
+ // The *calendar's* colour. Kept as a fallback so a migration that
+ // folds several source calendars into one keeps them apart visually.
+ "X-FOSSIFY-CATEGORY-COLOR", "X-SMT-CATEGORY-COLOR", "CATEGORY_COLOR" ->
+ calendarColor = parseIcsColorValue(line.value) ?: calendarColor
}
i++
}
@@ -150,37 +261,125 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
return null
}
+ // Most producers give a VTODO only a DUE, which is then the moment the
+ // task sits at. One that carries a DTSTART too means the span between
+ // them, so DUE stands in for the end it has no DTEND for. Resolved after
+ // the sweep rather than inside it: property order isn't guaranteed, and
+ // a DUE ahead of the DTSTART would otherwise be taken for the start.
+ if (isTask && due != null) {
+ if (dtStart == null) dtStart = due else if (dtEnd == null) dtEnd = due
+ }
val start = dtStart ?: run {
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
return null
}
- val end = dtEnd
- ?: duration?.let {
- start.copy(
- instant = Instant.fromEpochMilliseconds(
- start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
- ),
- )
- }
- ?: start
+ val recurrence = sanitizeRrule(rrule)
+ if (recurrence.repaired) warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
+ val end = resolveEnd(start, dtEnd, duration)
return ParsedIcsEvent(
uid = uid,
summary = summary,
start = start.instant,
- end = end.instant,
+ end = end,
isAllDay = start.isAllDay,
zoneId = start.zoneId,
- recurrenceRule = rrule,
+ recurrenceRule = recurrence.rule,
location = location,
description = description,
reminderMinutes = reminders.distinct(),
status = status,
availability = availability,
- calendarName = calendarName,
+ calendarName = calendarName ?: category,
+ color = eventColor ?: calendarColor,
+ exDates = if (recurrence.rule == null) {
+ emptyList()
+ } else {
+ normalizeExDates(exDateLines, start)
+ },
+ isTask = isTask,
)
}
- /** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
+ /**
+ * The event's end instant.
+ *
+ * All-day events get two RFC 5545 §3.6.1 rules applied that a literal read
+ * would miss: a `DATE`-valued `DTSTART` with no `DTEND` lasts one day, and —
+ * since the provider expands a zero-length all-day series into no instances
+ * at all — an all-day event may never end at or before it starts.
+ */
+ private fun resolveEnd(
+ start: IcsDateTime,
+ dtEnd: IcsDateTime?,
+ duration: String?,
+ ): Instant {
+ val explicit = dtEnd?.instant ?: duration?.let {
+ Instant.fromEpochMilliseconds(
+ start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
+ )
+ }
+ if (!start.isAllDay) return explicit ?: start.instant
+ return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1)
+ }
+
+ /**
+ * `EXDATE` values in the shape `Events.EXDATE` wants: `yyyyMMdd` for an
+ * all-day series, a UTC `yyyyMMddTHHmmssZ` stamp otherwise.
+ *
+ * Fossify stores its excluded occurrences as bare day codes and writes them
+ * out that way even for timed series, where the property is then ambiguous.
+ * Such a value is resolved against the series' own time of day, which is
+ * what it meant — its repetitions never move within the day.
+ *
+ * An all-day exclusion names a calendar day, so a zone-qualified DATE-TIME
+ * still means the day it spells out; resolving it to an instant first and
+ * reading *that* back in UTC would roll a midnight east of UTC onto the day
+ * before and exclude an occurrence that doesn't exist.
+ */
+ private fun normalizeExDates(lines: List, start: IcsDateTime): List {
+ if (lines.isEmpty()) return emptyList()
+ val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone
+ val startTime = start.instant.toLocalDateTime(startZone).time
+ return lines
+ .flatMap { line -> line.value.split(',').map { line to it.trim() } }
+ .mapNotNull { (line, token) ->
+ when {
+ token.isEmpty() -> null
+ start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode)
+ token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp)
+ else -> parseBasicDate(token)
+ ?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) }
+ }
+ }
+ .distinct()
+ }
+
+ /**
+ * [startZone] is the series' own zone, which a floating value (no `Z`, no
+ * `TZID`) has to be read in — RFC 5545 ties `EXDATE` to `DTSTART`'s form,
+ * and reading it in the device's zone instead would put the exclusion on an
+ * instant no occurrence has.
+ */
+ private fun parseExDateTime(
+ token: String,
+ line: IcsContentLine,
+ startZone: TimeZone,
+ ): Instant? {
+ val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
+ val zone = when {
+ token.endsWith("Z") -> TimeZone.UTC
+ else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
+ ?: startZone
+ }
+ return ldt.toInstant(zone)
+ }
+
+ /**
+ * A `VALARM`'s offset from the event start, in minutes *before* it — so a
+ * trigger that fires after the start (which is how the Fossify family
+ * encodes "on the day at 09:00") comes back negative. Null when the trigger
+ * isn't a usable relative offset.
+ */
private fun parseAlarmMinutes(body: List): Int? {
val trigger = body.asSequence()
.mapNotNull { parseContentLine(it) }
@@ -189,8 +388,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
val millis = parseRfc2445DurationMillis(trigger.value)
- // Negative = before start (the normal case) → positive lead minutes.
- return (-millis / 60_000L).toInt().coerceAtLeast(0)
+ return (-millis / 60_000L).toInt()
}
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet): IcsDateTime? {
@@ -218,6 +416,34 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
fun IcsContentLine.isBegin(component: String) =
name == "BEGIN" && value.trim().equals(component, true)
+ fun Instant.plusDays(days: Int): Instant =
+ Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
+
+ fun dayCode(date: LocalDate): String =
+ "%04d%02d%02d".format(date.year, date.month.number, date.day)
+
+ fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
+ "%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second)
+ }
+
+ /**
+ * A single-valued `CATEGORIES`, which is what the Fossify family writes
+ * for the source calendar's name. A multi-item list is a tag list and
+ * names no calendar, so it is ignored.
+ */
+ fun singleCategory(raw: String): String? {
+ var escaped = false
+ for (c in raw) {
+ when {
+ escaped -> escaped = false
+ c == '\\' -> escaped = true
+ c == ',' -> return null
+ }
+ }
+ return unescapeText(raw).trim()
+ .takeIf { it.isNotEmpty() && !it.equals("null", true) }
+ }
+
/** Index of the matching `END:` at/after [from], or list end. */
fun indexOfEnd(lines: List, from: Int, component: String): Int {
var i = from
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt
new file mode 100644
index 0000000..7f67573
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt
@@ -0,0 +1,132 @@
+package de.jeanlucmakiola.calendula.domain.ics
+
+private val VALID_FREQ = setOf(
+ "SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
+)
+
+private val VALID_WEEKDAYS = setOf("SU", "MO", "TU", "WE", "TH", "FR", "SA")
+
+/**
+ * What `EventRecurrence` accepts in each numeric list part: a value range, plus
+ * whether `0` is legal. Its `parseNumberList` throws on a non-number, on an item
+ * outside the range *and* on a zero in the by-position parts, so checking only
+ * that an item is a number would still hand the provider a rule it rejects.
+ *
+ * `BYSETPOS` is the one part it leaves unbounded; `0` is dropped from it anyway
+ * because RFC 5545 §3.3.10 gives it no meaning.
+ */
+private val NUMERIC_LIST_PARTS = mapOf(
+ "BYSECOND" to NumericPart(0..59, zeroAllowed = true),
+ "BYMINUTE" to NumericPart(0..59, zeroAllowed = true),
+ "BYHOUR" to NumericPart(0..23, zeroAllowed = true),
+ "BYMONTHDAY" to NumericPart(-31..31, zeroAllowed = false),
+ "BYYEARDAY" to NumericPart(-366..366, zeroAllowed = false),
+ "BYWEEKNO" to NumericPart(-53..53, zeroAllowed = false),
+ "BYMONTH" to NumericPart(1..12, zeroAllowed = false),
+ "BYSETPOS" to NumericPart(Int.MIN_VALUE..Int.MAX_VALUE, zeroAllowed = false),
+)
+
+private class NumericPart(private val range: IntRange, private val zeroAllowed: Boolean) {
+ fun accepts(item: String): Boolean {
+ val n = item.toIntOrNull() ?: return false
+ return n in range && (zeroAllowed || n != 0)
+ }
+}
+
+/** `UNTIL` in RFC 5545 basic format; anything else fails at expansion time. */
+private val UNTIL_SHAPE = Regex("""\d{8}(T\d{6}Z?)?""")
+
+/**
+ * A foreign RRULE made safe to hand `CalendarContract`.
+ *
+ * [rule] is what survived, or null when there is nothing left to salvage.
+ * [repaired] is true only when a part was actually dropped — pure normalisation
+ * (case, a stray separator) doesn't count, so a conformant file is never
+ * reported to the user as faulty.
+ */
+data class SanitizedRrule(val rule: String?, val repaired: Boolean)
+
+/**
+ * Salvage a foreign RRULE.
+ *
+ * The provider validates every RRULE through `EventRecurrence.parse`, which
+ * **throws** on a malformed rule — and the throw surfaces from `insert`, not
+ * from a later read. One bad rule in an imported file would therefore abort the
+ * write it rides on. Producers do emit such rules: Fossify's exporter writes a
+ * bare `;BYDAY=` whenever a weekly event carries no weekday mask
+ * (`Parser.getByDay` interpolates an empty day string unconditionally), which is
+ * the state every weekly event it imported from elsewhere is left in.
+ *
+ * That parser has a fixed table of parts and throws on any name outside it, so
+ * this is a whitelist: an unrecognised part (RFC 7529 `RSCALE`, a vendor `X-`
+ * extension) and an unusable value are dropped rather than passed on to fail the
+ * whole event. Only a missing or unrecognised `FREQ` — without which there is no
+ * rule at all — gives up entirely.
+ */
+fun sanitizeRrule(raw: String?): SanitizedRrule {
+ val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
+ if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false)
+
+ var freqSeen = false
+ var repaired = false
+ val parts = mutableListOf()
+
+ /** The items [valid] accepts, uppercased and comma-joined; null if none survive. */
+ fun keepItems(value: String, valid: (String) -> Boolean): String? {
+ val items = value.split(',').map { it.trim().uppercase() }
+ val kept = items.filter(valid)
+ if (kept.size != items.size) repaired = true
+ return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
+ }
+
+ for (part in rule.split(';')) {
+ // A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
+ // A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
+ // being dropped, so it falls through and marks the rule repaired.
+ if (part.isBlank()) continue
+ val key = part.substringBefore('=', "").trim().uppercase()
+ val value = if ('=' in part) part.substringAfter('=').trim() else ""
+ val kept = when {
+ key.isEmpty() || value.isEmpty() -> null
+ key == "FREQ" ->
+ value.uppercase().takeIf { it in VALID_FREQ }
+ ?.also { freqSeen = true }
+ ?.let { "FREQ=$it" }
+ // A non-positive or non-numeric INTERVAL/COUNT is rejected by the
+ // provider, and a COUNT of zero would expand to no occurrences.
+ key == "INTERVAL" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "INTERVAL=$it" }
+ key == "COUNT" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "COUNT=$it" }
+ key == "UNTIL" ->
+ value.uppercase().takeIf { UNTIL_SHAPE.matches(it) }?.let { "UNTIL=$it" }
+ key == "WKST" ->
+ value.uppercase().takeIf { it in VALID_WEEKDAYS }?.let { "WKST=$it" }
+ key == "BYDAY" -> keepItems(value, ::isWeekdayItem)?.let { "BYDAY=$it" }
+ key in NUMERIC_LIST_PARTS ->
+ keepItems(value, NUMERIC_LIST_PARTS.getValue(key)::accepts)?.let { "$key=$it" }
+ else -> null
+ }
+ if (kept == null) repaired = true else parts += kept
+ }
+ // `EventRecurrence.parse` ends with *two* global checks, not one: a missing
+ // FREQ throws, and so does an UNTIL that arrives together with a COUNT. A
+ // rule carrying both is malformed per RFC 5545 §3.3.10 but real producers
+ // emit it, and left alone it would throw straight out of `insert` — the very
+ // failure this function exists to prevent. UNTIL is kept: it bounds the
+ // series at an absolute date, so a stale COUNT can't over-generate past it.
+ if (parts.any { it.startsWith("UNTIL=") }) {
+ if (parts.removeAll { it.startsWith("COUNT=") }) repaired = true
+ }
+ return if (freqSeen) {
+ SanitizedRrule(rule = parts.joinToString(";"), repaired = repaired)
+ } else {
+ SanitizedRrule(rule = null, repaired = true)
+ }
+}
+
+/** A `BYDAY` item: a two-letter weekday, optionally preceded by an ordinal. */
+private fun isWeekdayItem(item: String): Boolean {
+ if (item.length < 2) return false
+ val ordinal = item.dropLast(2)
+ return item.takeLast(2) in VALID_WEEKDAYS &&
+ (ordinal.isEmpty() || ordinal.toIntOrNull() != null)
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt
index 8d3e604..c26dd9c 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt
@@ -46,6 +46,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
appendTimes(event)
event.recurrenceRule?.takeIf { it.isNotBlank() }
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
+ appendExDates(event)
event.location?.takeIf { it.isNotBlank() }
?.let { add("LOCATION:${escapeText(it)}") }
event.description?.takeIf { it.isNotBlank() }
@@ -83,6 +84,20 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
}
}
+ /**
+ * The series' deleted occurrences. RFC 5545 ties `EXDATE`'s value type to
+ * `DTSTART`'s, so an all-day series needs the explicit `VALUE=DATE` — without
+ * it the bare day codes read as an invalid DATE-TIME. Only written for a
+ * recurring event: an exclusion names an occurrence, and a one-off has none.
+ */
+ private fun MutableList.appendExDates(event: IcsEvent) {
+ if (event.recurrenceRule.isNullOrBlank()) return
+ val stamps = event.exDates.filter { it.isNotBlank() }.distinct()
+ if (stamps.isEmpty()) return
+ val prefix = if (event.isAllDay) "EXDATE;VALUE=DATE:" else "EXDATE:"
+ add(prefix + stamps.joinToString(","))
+ }
+
private fun MutableList.appendAlarm(minutes: Int, summary: String) {
add("BEGIN:VALARM")
add("ACTION:DISPLAY")
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt
index 3e8ff04..d2609a9 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt
@@ -13,6 +13,11 @@ import kotlinx.datetime.toLocalDateTime
* calendar, exactly like a fresh create — the user confirms the target and
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
* handling (provider all-day times are UTC midnights with an exclusive end).
+ *
+ * [ParsedIcsEvent.color] is deliberately not carried over: picking the calendar
+ * is the first thing this form asks for, and that clears any colour (a raw ARGB
+ * is invalid on a calendar whose account publishes a palette). Bulk import,
+ * where the target is known up front, does keep it.
*/
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
val (start, end) = if (isAllDay) {
@@ -31,7 +36,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
end = end,
location = location.orEmpty(),
description = description.orEmpty(),
- reminders = reminderMinutes.distinct().sorted(),
+ reminders = semanticReminderMinutes(),
availability = availability,
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt
index ef17d08..559cc51 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt
@@ -63,6 +63,12 @@ private const val MINUTES_PER_DAY = 1_440
* for *which day* it means ([allDayLeadDays]) and take the hour from
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
* Duplicate offsets in [minutesByEvent] collapse.
+ *
+ * Every offset given is planned. The provider's "use the account default"
+ * sentinel names a lead time no reader can resolve, and is translated out on the
+ * way in
+ * ([de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults]) — taken
+ * as an offset it would arm an alarm a minute after the event began.
*/
fun planReminders(
instances: List,
@@ -70,17 +76,19 @@ fun planReminders(
zone: ZoneId,
allDayTimeMinutes: Int,
): List = instances.flatMap { instance ->
- minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes ->
- PlannedReminder(
- instance = instance,
- minutes = minutes,
- alarmMillis = if (instance.isAllDay) {
- allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
- } else {
- instance.beginMillis - minutes * MILLIS_PER_MINUTE
- },
- )
- }
+ minutesByEvent[instance.eventId].orEmpty()
+ .distinct()
+ .map { minutes ->
+ PlannedReminder(
+ instance = instance,
+ minutes = minutes,
+ alarmMillis = if (instance.isAllDay) {
+ allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
+ } else {
+ instance.beginMillis - minutes * MILLIS_PER_MINUTE
+ },
+ )
+ }
}
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
index fa32b89..2ef93d5 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
@@ -319,9 +319,10 @@ fun CalendarHost(
EventMoveScope(
movableCalendarIds = movableCalendarIds,
dragEnabled = dragToReschedule,
- move = reschedule::move,
+ move = { reschedule.move(it) },
inFlight = reschedule.inFlight,
undoStarted = reschedule.undoStarted,
+ abandoned = reschedule.abandoned,
edit = onEditEvent,
)
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventChipShape.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventChipShape.kt
new file mode 100644
index 0000000..6bc0d12
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventChipShape.kt
@@ -0,0 +1,35 @@
+package de.jeanlucmakiola.calendula.ui.common
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.ui.unit.dp
+
+/** The corner radius every event chip keeps on each edge the event doesn't cross. */
+val EVENT_CHIP_CORNER = 4.dp
+
+/** An event chip that is cut on no edge — an all-day bar, a floating copy. */
+val EventChipShape = RoundedCornerShape(EVENT_CHIP_CORNER)
+
+/**
+ * A timed block's corners: square on whichever edge the event runs past, so a
+ * cut edge reads as "this carries on" rather than as the event's own end. The
+ * vertical counterpart of [monthBarShape].
+ */
+fun timedBlockShape(continuesBefore: Boolean, continuesAfter: Boolean): RoundedCornerShape =
+ RoundedCornerShape(
+ topStart = if (continuesBefore) 0.dp else EVENT_CHIP_CORNER,
+ topEnd = if (continuesBefore) 0.dp else EVENT_CHIP_CORNER,
+ bottomStart = if (continuesAfter) 0.dp else EVENT_CHIP_CORNER,
+ bottomEnd = if (continuesAfter) 0.dp else EVENT_CHIP_CORNER,
+ )
+
+/**
+ * A month/all-day bar's corners: square on whichever side the event runs past
+ * the row it is drawn in.
+ */
+fun monthBarShape(continuesLeft: Boolean, continuesRight: Boolean): RoundedCornerShape =
+ RoundedCornerShape(
+ topStart = if (continuesLeft) 0.dp else EVENT_CHIP_CORNER,
+ bottomStart = if (continuesLeft) 0.dp else EVENT_CHIP_CORNER,
+ topEnd = if (continuesRight) 0.dp else EVENT_CHIP_CORNER,
+ bottomEnd = if (continuesRight) 0.dp else EVENT_CHIP_CORNER,
+ )
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt
index c338e40..c49456b 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt
@@ -35,8 +35,12 @@ class EventMoveScope(
* accidental drags the setting exists to stop.
*/
val dragEnabled: Boolean,
- /** False when the drop was refused outright, so nothing will be written. */
- val move: (MoveRequest) -> Boolean,
+ /**
+ * Hand a drop to the writer. Returns nothing on purpose: a drop is answered
+ * before its write resolves, so [abandoned] — not a return value — is what
+ * tells a held copy that nothing landed.
+ */
+ val move: (MoveRequest) -> Unit,
/**
* True while a dropped event is being written, including the time its scope
* dialog is up. A flow rather than a value so this scope stays the same
@@ -45,6 +49,12 @@ class EventMoveScope(
val inFlight: StateFlow,
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
val undoStarted: StateFlow,
+ /**
+ * Ticks when a drop ends with nothing landing — see
+ * `RescheduleViewModel.abandoned`. [move] answers before the write is
+ * resolved, so this is what tells a held copy to stop waiting.
+ */
+ val abandoned: StateFlow,
/** Open an event in the edit form — the pointer-free route to the same change. */
val edit: (EventInstance) -> Unit,
) {
@@ -57,6 +67,8 @@ private val NEVER_IN_FLIGHT = MutableStateFlow(false)
private val NEVER_UNDONE = MutableStateFlow(0)
+private val NEVER_ABANDONED = MutableStateFlow(0)
+
/**
* Whether a dropped event is still being written — false wherever moving is off.
* The drag overlays hold a landed block on its target for this window.
@@ -67,6 +79,17 @@ fun moveInFlight(): Boolean {
return flow.collectAsStateWithLifecycle().value
}
+/**
+ * How many drops have ended with nothing landing. Read as a plain count so a
+ * drag overlay can capture it at the moment of the drop and tell, later, whether
+ * *its own* drop was one of them.
+ */
+@Composable
+fun abandonedMoves(): Int {
+ val flow = LocalEventMove.current?.abandoned ?: NEVER_ABANDONED
+ return flow.collectAsStateWithLifecycle().value
+}
+
/**
* Runs [onUndo] when an undo write begins, and never for one that began before
* this composable came on screen.
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt
index 1ee9778..3812dee 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt
@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.data.calendar.MINUTES_DEFAULT
import de.jeanlucmakiola.floret.reminders.ReminderUnit
/** Common reminder lead times offered as quick picks in the form and settings. */
@@ -20,15 +21,20 @@ fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
/**
* Humanise a reminder lead time (minutes before the event start) into one
- * line: "Default reminder" (negative = the provider default), "At time of
- * event" (0), "10 minutes before", "1 hour before", … Shared by the detail
- * screen, the event form and the default-reminder settings so the wording
- * never drifts.
+ * line: "Default reminder" ([MINUTES_DEFAULT]), "At time of event" (0),
+ * "10 minutes before", "1 hour before", … Shared by the detail screen, the event
+ * form and the default-reminder settings so the wording never drifts.
+ *
+ * Only the sentinel itself is the account default. Any other negative is a real
+ * lead time firing *after* the start — rare, but a foreign all-day row decodes to
+ * one — and naming it "Default reminder" would tell the user an alarm that does
+ * go off is not ours.
*/
@Composable
fun reminderLeadTimeLabel(minutes: Int): String = when {
- minutes < 0 -> stringResource(R.string.reminder_default)
+ minutes == MINUTES_DEFAULT -> stringResource(R.string.reminder_default)
minutes == 0 -> stringResource(R.string.reminder_at_time)
+ minutes < 0 -> stringResource(R.string.reminder_after, durationLabel(-minutes))
minutes % 10_080 == 0 ->
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
minutes % 1_440 == 0 ->
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt
index 21d6257..003506b 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt
@@ -140,6 +140,17 @@ class RescheduleViewModel @Inject constructor(
*/
val inFlight: StateFlow = _inFlight.asStateFlow()
+ private val _abandoned = MutableStateFlow(0)
+
+ /**
+ * Ticks when a drop ends with nothing on the target day: refused outright,
+ * refused before the write, written and failed, or its scope dialog
+ * dismissed. [move] answers before any of that is known, so without this
+ * signal the view that drew the drop held its copy out for the full settle
+ * timeout waiting for a grid that was never going to draw it (#253).
+ */
+ val abandoned: StateFlow = _abandoned.asStateFlow()
+
private val _undoStarted = MutableStateFlow(0)
/**
@@ -189,15 +200,23 @@ class RescheduleViewModel @Inject constructor(
/**
* Take a drop, unless one is already being written. False means nothing will
- * be written and the caller must let its held copy go now — waiting on
- * [inFlight] would strand it until the settle timeout instead.
+ * be written; [abandoned] ticks for that refusal too, so a held copy is let
+ * go by the one signal rather than by every caller remembering to.
+ *
+ * The Boolean is diagnostic — the tests read it, [EventMoveScope] does not
+ * hand it on. A caller that gated on it would be back to releasing its copy
+ * from two places, and it answers before the write is resolved anyway.
*/
fun move(request: MoveRequest): Boolean {
- if (busy || _scopePrompt.value != null) return false
+ if (busy || _scopePrompt.value != null) {
+ _abandoned.value += 1
+ return false
+ }
busy = true
viewModelScope.launch {
val prepared = prepare(request)
if (prepared == null) {
+ _abandoned.value += 1
busy = false
return@launch
}
@@ -231,6 +250,7 @@ class RescheduleViewModel @Inject constructor(
pending = null
busy = false
_scopePrompt.value = null
+ _abandoned.value += 1
}
/**
@@ -238,6 +258,12 @@ class RescheduleViewModel @Inject constructor(
* inverse write reports back, so the one chip changes what it says rather
* than closing and reopening. False when another write is already running —
* the chip hides its action for that window, so this is the backstop.
+ *
+ * [abandoned] ticks once an inverse that *started* fails to land: [undoStarted]
+ * has sent the chip back on its journey by then and nothing else will seat it.
+ * A refusal ticks nothing — it launched no journey of its own, and the counter
+ * is global, so a tick here would release some other drop's held copy before
+ * the grid has drawn it.
*/
fun undo(undo: MoveUndo): Boolean {
if (busy) return false
@@ -256,6 +282,9 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) {
MoveOutcome.Failed
}
+ // Read off the outcome, the way [write] does — no second copy of the
+ // same fact to drift from it.
+ if (_outcome.value !is MoveOutcome.Undone) _abandoned.value += 1
busy = false
}
return true
@@ -340,6 +369,7 @@ class RescheduleViewModel @Inject constructor(
val request = prepared.request
if (endsBeforeItStarts(prepared, scope)) {
_outcome.value = MoveOutcome.BlockedSeriesEnd
+ _abandoned.value += 1
return
}
_outcome.value = try {
@@ -351,6 +381,7 @@ class RescheduleViewModel @Inject constructor(
repository.updateOccurrence(
request.eventId,
request.beginMillis,
+ prepared.original,
prepared.updated,
)
@@ -375,6 +406,9 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) {
MoveOutcome.Failed
}
+ // Nothing landed, so the copy the view is holding has nothing to fade
+ // into — let it go now rather than at the end of the settle timeout.
+ if (_outcome.value !is MoveOutcome.Moved) _abandoned.value += 1
}
/**
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt
index 9420cdd..85f6dd5 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt
@@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
@@ -44,7 +45,11 @@ import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
+import kotlinx.datetime.atStartOfDayIn
+import kotlinx.datetime.plus
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
@@ -66,17 +71,148 @@ private val AUTO_SCROLL_STEP = 16.dp
data class TimelineDrag(
val event: EventInstance,
val date: LocalDate,
+ /**
+ * Where the dragged *block's* top edge now sits, in minutes from [date]'s
+ * midnight. The gesture's own quantity — what snaps, what the haptics tick
+ * on, and what the drop is expressed in. Not the event's start: for the tail
+ * of an event that began the day before, the two are a clip offset apart.
+ */
val startMin: Int,
- val endMin: Int,
- val topLeftInRoot: Offset,
- val sizePx: IntSize,
+ /** The event's new start, from [date]'s midnight — negative if it began earlier. */
+ val eventStartMin: Int,
+ val eventSpanMin: Int,
+ /** The event as the grid would draw it, one piece per day column it covers. */
+ val pieces: List,
+ /**
+ * True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left
+ * every column this timeline shows. A hint under the finger, not where the
+ * drop lands, so it is dropped once the gesture ends ([finish]).
+ */
+ val edgeOnly: Boolean = false,
) {
/** What the snap haptics key off: one tick per changed slot, not per frame. */
val slot: Pair get() = date to startMin
}
-/** Where a finished drag asks its event to go. */
-data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int)
+/**
+ * One day column's share of a dragged event, ready to draw. Both halves of an
+ * event crossing midnight are previewed, whichever half the finger picked up:
+ * showing only the held one made the event read as growing rather than moving,
+ * since the day beside it still stood at the block's old extent (#253).
+ */
+data class TimelineDragPiece(
+ val topLeftInRoot: Offset,
+ val sizePx: IntSize,
+ val continuesBefore: Boolean,
+ val continuesAfter: Boolean,
+)
+
+/**
+ * How a run of [spanMin] minutes beginning [eventStartMin] minutes into a day —
+ * negative when it began on an earlier one — falls across day columns, as
+ * offsets from that day. Every slice but the first and last is a whole day, and
+ * the flags say which edges are cuts rather than the event's own ends.
+ *
+ * [within] bounds the day offsets that have a column to draw in, so a very long
+ * event doesn't slice days no one can see.
+ */
+internal fun dragSlices(
+ eventStartMin: Int,
+ spanMin: Int,
+ within: IntRange? = null,
+): List {
+ val span = spanMin.coerceAtLeast(0)
+ val end = eventStartMin + span
+ val days = dragDayRange(eventStartMin, spanMin)
+ val first = days.first
+ val last = days.last
+ val from = maxOf(first, within?.first ?: first)
+ val to = minOf(last, within?.last ?: last)
+ if (from > to) return emptyList()
+ return (from..to).map { day ->
+ val dayStart = day * MINUTES_PER_DAY
+ val sliceStart = maxOf(eventStartMin - dayStart, 0)
+ val sliceEnd = minOf(end - dayStart, MINUTES_PER_DAY)
+ DragSlice(
+ dayOffset = day,
+ startMin = sliceStart,
+ spanMin = sliceEnd - sliceStart,
+ continuesBefore = day > first,
+ continuesAfter = day < last,
+ )
+ }
+}
+
+/**
+ * The one piece to draw when a drag has carried the whole event clear of the
+ * columns this timeline shows, so [dragSlices] has nothing to hand back — where a
+ * short clipped tail lands at its [dragFloorMin]: the event then belongs to the
+ * day before, which Day view has no column for, and the copy would blink out from
+ * under the finger mid-gesture. Pinned to the top of the nearest visible column
+ * at minimum height instead, cut at that edge, so it reads as having slid off the
+ * top. Null whenever a real slice exists. The drop is unaffected either way — it
+ * reads [TimelineDrag.startMin], not the pieces.
+ */
+internal fun edgeDragSlice(eventStartMin: Int, spanMin: Int, within: IntRange): DragSlice? {
+ if (dragDayRange(eventStartMin, spanMin).last >= within.first) return null
+ return DragSlice(
+ dayOffset = within.first,
+ startMin = 0,
+ spanMin = 0,
+ continuesBefore = true,
+ continuesAfter = false,
+ )
+}
+
+/**
+ * The day offsets a run of [spanMin] minutes beginning [eventStartMin] covers,
+ * before any clipping to what a timeline shows. One rule, so [dragSlices] and
+ * [edgeDragSlice] can't drift apart on where an event's days begin and end.
+ */
+private fun dragDayRange(eventStartMin: Int, spanMin: Int): IntRange {
+ val end = eventStartMin + spanMin.coerceAtLeast(0)
+ val first = eventStartMin.floorDiv(MINUTES_PER_DAY)
+ // A zero-length event still has a block, on the one day it sits in.
+ return first..maxOf((end - 1).floorDiv(MINUTES_PER_DAY), first)
+}
+
+/**
+ * How far above its day's midnight a block's top edge may be dragged. Zero for
+ * an ordinary block, whose top edge is the event's own start. A tail clipped at
+ * midnight has to go lower: its top edge *is* midnight and stays there however
+ * much earlier the event moves, so pinning it at zero made dragging the second
+ * half earlier a no-op (#253). It may rise until the event's end reaches one
+ * snap step into this day — or by a single step, for a tail too short to afford
+ * even that, which then leaves the day for the one before it.
+ */
+internal fun dragFloorMin(clipOffsetMin: Int, eventSpanMin: Int): Int =
+ if (clipOffsetMin > 0) {
+ minOf(-DRAG_SNAP_MINUTES, DRAG_SNAP_MINUTES + clipOffsetMin - eventSpanMin)
+ } else {
+ 0
+ }
+
+/** One day's share of a dragged event, before it is placed on screen. */
+internal data class DragSlice(
+ val dayOffset: Int,
+ val startMin: Int,
+ val spanMin: Int,
+ val continuesBefore: Boolean,
+ val continuesAfter: Boolean,
+)
+
+/**
+ * Where a finished drag asks its event to go. [date]/[startMin] are where the
+ * dragged *block's* top edge landed, which is the event's own start only for a
+ * block that isn't clipped — see [clipOffsetMin] and [startInstant].
+ */
+data class TimelineDrop(
+ val event: EventInstance,
+ val date: LocalDate,
+ val startMin: Int,
+ /** Minutes from the event's start to the dragged block's top edge. */
+ val clipOffsetMin: Int = 0,
+)
/**
* The timeline's live geometry, republished on every layout. Plain fields rather
@@ -134,12 +270,14 @@ class TimelineDragController {
private set
/**
- * Where the settled drop came from, and which event row it belongs to. The
- * instance id alone can't identify the source block for the length of the
- * write: the provider regenerates `Instances` rows, so a re-read carrying the
- * *old* time can arrive under a new instance id.
+ * The event row and start instant the settled drop came from. The instance id
+ * alone can't identify the source for the length of the write: the provider
+ * regenerates `Instances` rows, so a re-read carrying the *old* time can
+ * arrive under a new instance id. Matching the start instant rather than a
+ * single day and slot keeps *both* halves of an event that crosses midnight
+ * ghosted until the write lands (#253).
*/
- private var settledOrigin: Triple? by mutableStateOf(null)
+ private var settledOrigin: Pair? by mutableStateOf(null)
/** Whether a finger is on a block right now — [settling] is not dragging. */
var isDragging: Boolean by mutableStateOf(false)
@@ -153,18 +291,42 @@ class TimelineDragController {
var settledOnGrid: Boolean by mutableStateOf(false)
private set
+ /**
+ * The abandoned-move counter, read at the drop so the mark can't be a
+ * composition behind the tick it is compared against.
+ */
+ var abandonedTicks: StateFlow? = null
+
+ /** What [abandonedTicks] stood at when the settling drop was let go. */
+ var settledAbandonedMark: Int = 0
+ private set
+
private var source: TimedBlock? = null
private var grab = Offset.Zero
private var pointer = Offset.Zero
+ /**
+ * How far the picked-up block's top edge sits past the event's own start —
+ * nonzero only for a block clipped at midnight, which is the tail of an
+ * event that began the day before (#253). The gesture tracks the block the
+ * finger is on; this is what turns where it lands back into an event start.
+ */
+ private var clipOffsetMin = 0
+
/**
* The slot the block already occupied when it was picked up — not the same
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
*/
private var originSlot: Pair? = null
- fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
+ fun begin(
+ block: TimedBlock,
+ clipOffsetMin: Int,
+ pointerInRoot: Offset,
+ blockInRoot: Offset,
+ ) {
source = block
+ this.clipOffsetMin = clipOffsetMin
settling = null
isDragging = true
liftedInstanceId = block.event.instanceId
@@ -182,6 +344,7 @@ class TimelineDragController {
fun cancel() {
source = null
+ clipOffsetMin = 0
isDragging = false
liftedInstanceId = null
originSlot = null
@@ -192,32 +355,41 @@ class TimelineDragController {
}
/**
- * Whether [block] is the one whose copy is in flight, and so must stay a
- * ghost: the instance the finger picked up, or once dropped whatever now
- * sits in the slot it left.
+ * Whether [block] is part of the event whose copy is in flight, and so must
+ * stay a ghost: the instance the finger picked up, or once dropped whatever
+ * the re-read now carries at the time it came from — both halves of it, for
+ * an event that crosses midnight (#253).
*/
- fun ghosts(block: TimedBlock, date: LocalDate): Boolean {
+ fun ghosts(block: TimedBlock): Boolean {
if (liftedInstanceId == null) return false
if (block.event.instanceId == liftedInstanceId) return true
- val (eventId, originDate, originMin) = settledOrigin ?: return false
- return block.event.eventId == eventId &&
- date == originDate &&
- block.startMin == originMin
+ val (eventId, start) = settledOrigin ?: return false
+ return block.event.eventId == eventId && block.event.start == start
}
/**
* What a day column now holds, so a settled drop can tell when the grid has
* caught up with it. Matched on the landing slot plus either the event row
- * or its title, since a single-occurrence move writes a new `eventId`.
+ * or its title, since a single-occurrence move writes a new `eventId`. A
+ * blank title matches nothing: every untitled event has one (#253).
*/
fun noteGrid(date: LocalDate, blocks: List) {
val landed = settling ?: return
- if (settledOnGrid || landed.date != date) return
+ if (settledOnGrid) return
+ // Where this day would draw it, which for a drop clipped at midnight is
+ // the day's own start rather than the edge the finger held (#253) — and
+ // which for a tail dragged clear of its day is the day before it.
+ val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
+ val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
+ .firstOrNull() ?: return
settledOnGrid = blocks.any { block ->
- block.startMin == landed.startMin &&
+ block.startMin == slice.startMin &&
(
block.event.eventId == landed.event.eventId ||
- block.event.title == landed.event.title
+ (
+ landed.event.title.isNotBlank() &&
+ block.event.title == landed.event.title
+ )
)
}
}
@@ -230,12 +402,18 @@ class TimelineDragController {
fun finish(): TimelineDrop? {
val landed = drag
val origin = originSlot
+ val clipOffset = clipOffsetMin
cancel()
if (landed == null || landed.slot == origin) return null
- settling = landed
+ // An edge piece is a hint for the finger, not a preview of the landing
+ // slot: the event belongs to a day this timeline has no column for, so
+ // [noteGrid] can never seat it and it would sit at the top of today for
+ // the whole settle timeout, long after the event moved off-screen.
+ settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId
- settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) }
- return TimelineDrop(landed.event, landed.date, landed.startMin)
+ settledOrigin = landed.event.eventId to landed.event.start
+ settledAbandonedMark = abandonedTicks?.value ?: 0
+ return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
}
/**
@@ -271,28 +449,47 @@ class TimelineDragController {
val origin = grid.positionInRoot()
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
- // Clamp the start into the target day; a tail past midnight is fine.
- val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
- // The event's own length, not the block's: TimedBlock.endMin is clipped
- // at midnight, which would draw a 22:00–02:00 event as a two-hour copy.
- val span = (block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0)
- // The column the finger is over on screen, and the day that column shows.
+ val eventSpan = (block.event.end - block.event.start).inWholeMinutes.toInt()
+ .coerceAtLeast(0)
+ val floor = dragFloorMin(clipOffsetMin, eventSpan)
+ val startMin = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
+ val eventStartMin = startMin - clipOffsetMin
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
- val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
+ val visible = -dayIndex..days.lastIndex - dayIndex
+ val onGrid = dragSlices(eventStartMin, eventSpan, within = visible)
+ val slices = onGrid.ifEmpty {
+ listOfNotNull(edgeDragSlice(eventStartMin, eventSpan, visible))
+ }
drag = TimelineDrag(
event = block.event,
date = days[dayIndex],
startMin = startMin,
- endMin = startMin + span,
- topLeftInRoot = Offset(
- x = origin.x + column * columnPx,
- y = origin.y + startMin / 60f * hourPx,
- ),
- sizePx = IntSize(
- (columnPx - geometry.columnGapPx).roundToInt(),
- height.roundToInt(),
- ),
+ eventStartMin = eventStartMin,
+ eventSpanMin = eventSpan,
+ // Bounded to the columns this timeline actually shows: a day it
+ // doesn't has no piece to draw. The write is unaffected.
+ pieces = slices
+ .map { slice ->
+ val day = dayIndex + slice.dayOffset
+ val col = if (geometry.isRtl) days.lastIndex - day else day
+ TimelineDragPiece(
+ topLeftInRoot = Offset(
+ x = origin.x + col * columnPx,
+ y = origin.y + slice.startMin / 60f * hourPx,
+ ),
+ sizePx = IntSize(
+ (columnPx - geometry.columnGapPx).roundToInt(),
+ maxOf(
+ slice.spanMin / 60f * hourPx,
+ MIN_EVENT_FRACTION * hourPx,
+ ).roundToInt(),
+ ),
+ continuesBefore = slice.continuesBefore,
+ continuesAfter = slice.continuesAfter,
+ )
+ },
+ edgeOnly = onGrid.isEmpty(),
)
}
@@ -334,24 +531,52 @@ fun rememberTimelineDragController(): TimelineDragController {
val density = LocalDensity.current
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
+ controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller
}
/**
- * True when [block] actually begins on [day] rather than being the tail of an
- * event that started earlier. A clipped block's top edge is midnight, not the
- * event's start, so dragging it would move the event to a time it never had.
+ * How far this block's top edge sits past its event's start, in minutes — zero
+ * for a block that begins on [day], and the part already run for the tail of an
+ * event that started earlier, whose top edge is midnight rather than the event's
+ * start. Dragging such a tail moves the event by where *its own* top edge lands,
+ * so the offset has to come back off at the drop (#253).
*/
-fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean =
- event.start.toLocalDateTime(zone).date == day
+fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
+ val start = event.start.toLocalDateTime(zone)
+ val days = (day.toEpochDays() - start.date.toEpochDays()).toInt()
+ val minutes = days * MINUTES_PER_DAY + startMin - start.time.toSecondOfDay() / 60
+ return minutes.coerceAtLeast(0)
+}
/**
- * The instant a drop asks for, read in the zone the timeline is drawn in. A drop
- * into a spring-forward gap has no instant of its own; `toInstant` resolves it
- * forward by the missing hour, and the block visibly settles there.
+ * True when the event began before [day] — this block is its tail, cut at
+ * midnight rather than starting where the event does.
*/
-fun TimelineDrop.startInstant(zone: TimeZone): Instant =
- LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone)
+fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
+ event.start < day.atStartOfDayIn(zone)
+
+/** True when the event runs past [day]'s midnight, so this block is cut there. */
+fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
+ event.end > day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
+
+/**
+ * The instant a drop asks for — the *event's* new start, so the tail of an
+ * overnight event drops back onto the day its own start belongs to. Read in the
+ * zone the timeline is drawn in. A drop into a spring-forward gap has no instant
+ * of its own; `toInstant` resolves it forward by the missing hour, and the block
+ * visibly settles there.
+ */
+fun TimelineDrop.startInstant(zone: TimeZone): Instant {
+ // Wall clock throughout, and past this day at either end: a block's top edge
+ // can be dragged above its midnight and a tail's below the next one, so the
+ // start is resolved as a day plus a minute of it rather than as an offset
+ // from an instant — which would shift by an hour across a DST boundary.
+ val fromMidnight = startMin - clipOffsetMin
+ val day = date.plus(fromMidnight.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
+ val minute = fromMidnight.mod(MINUTES_PER_DAY)
+ return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
+}
/**
* A beat after the grid draws the drop, so its own block is under way before the
@@ -384,7 +609,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val reduceMotion = rememberReduceMotion()
- val density = LocalDensity.current
val moveInFlight = moveInFlight()
// Both live here rather than beside the controller: they read [drag], which
@@ -397,9 +621,14 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// recurring drop's scope dialog is up — and then until the grid draws the
// drop itself.
var handingOver by remember(controller.settling) { mutableStateOf(false) }
- LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
+ val abandoned = abandonedMoves()
+ LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid, abandoned) {
if (controller.settling == null || moveInFlight) return@LaunchedEffect
- delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
+ // Past the mark this drop was let go on, it was abandoned: nothing will
+ // land and there is nothing to wait for.
+ if (abandoned == controller.settledAbandonedMark) {
+ delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
+ }
handingOver = true
controller.handOver()
delay(SETTLE_FADE_MILLIS.toLong())
@@ -428,49 +657,84 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
)
val fill = eventFill(drag.event.color, dark, soften)
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
- // An end past midnight wraps rather than saturating, so a four-hour
- // event dragged to 22:00 reads "22:00–02:00" and not "22:00–24:00".
- val endMin = if (drag.endMin > MINUTES_PER_DAY) {
- drag.endMin % MINUTES_PER_DAY
- } else {
- drag.endMin
- }
- val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" +
+ // Read off the event rather than the held block, so grabbing either half
+ // of an event that crosses midnight names the same hours. An end past
+ // midnight wraps rather than saturating, so a four-hour event dragged to
+ // 22:00 reads "22:00–02:00" and not "22:00–24:00".
+ val startMin = drag.eventStartMin.mod(MINUTES_PER_DAY)
+ val rawEnd = startMin + drag.eventSpanMin
+ val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
+ val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}–" +
formatMinuteOfDay(endMin, use24Hour, locale)
- Box(
- modifier = Modifier
- // Absolute: these are root coordinates, and the direction-aware
- // offset would mirror them across the screen in an RTL layout.
- .absoluteOffset {
- IntOffset(
- (drag.topLeftInRoot.x - origin.x).roundToInt(),
- (drag.topLeftInRoot.y - origin.y).roundToInt(),
- )
- }
- .size(
- width = with(density) { drag.sizePx.width.toDp() },
- height = with(density) { drag.sizePx.height.toDp() },
- )
- .padding(horizontal = 1.dp)
- .graphicsLayer {
- scaleX = 1f + 0.02f * lift
- scaleY = 1f + 0.02f * lift
- shadowElevation = 8.dp.toPx() * lift
- alpha = copyAlpha
- shape = RoundedCornerShape(4.dp)
- clip = false
- }
- .background(fill, RoundedCornerShape(4.dp))
- .padding(horizontal = 4.dp, vertical = 2.dp),
- ) {
- Column {
- Text(
- text = title,
- style = MaterialTheme.typography.labelMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- color = eventInk(fill, alpha = 0.85f),
+ // The tallest piece carries the range: on the smallest it would be
+ // clipped away, which is exactly the case when a short tail is held.
+ val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height }
+ drag.pieces.forEachIndexed { index, piece ->
+ DragCopy(
+ topLeftInRoot = piece.topLeftInRoot,
+ overlayOrigin = origin,
+ sizePx = piece.sizePx,
+ fill = fill,
+ shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
+ lift = lift,
+ alpha = copyAlpha,
+ title = title,
+ // Once for the whole event: repeated, it would name it per day.
+ label = label.takeIf { index == labelled },
+ )
+ }
+ }
+}
+
+/** One piece of a dragged block, floating over the calendar. */
+@Composable
+private fun DragCopy(
+ topLeftInRoot: Offset,
+ overlayOrigin: Offset,
+ sizePx: IntSize,
+ fill: Color,
+ shape: RoundedCornerShape,
+ lift: Float,
+ alpha: Float,
+ title: String,
+ label: String?,
+) {
+ val density = LocalDensity.current
+ Box(
+ modifier = Modifier
+ // Absolute: these are root coordinates, and the direction-aware
+ // offset would mirror them across the screen in an RTL layout.
+ .absoluteOffset {
+ IntOffset(
+ (topLeftInRoot.x - overlayOrigin.x).roundToInt(),
+ (topLeftInRoot.y - overlayOrigin.y).roundToInt(),
)
+ }
+ .size(
+ width = with(density) { sizePx.width.toDp() },
+ height = with(density) { sizePx.height.toDp() },
+ )
+ .padding(horizontal = 1.dp)
+ .graphicsLayer {
+ scaleX = 1f + 0.02f * lift
+ scaleY = 1f + 0.02f * lift
+ shadowElevation = 8.dp.toPx() * lift
+ this.alpha = alpha
+ this.shape = shape
+ clip = false
+ }
+ .background(fill, shape)
+ .padding(horizontal = 4.dp, vertical = 2.dp),
+ ) {
+ Column {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.labelMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = eventInk(fill, alpha = 0.85f),
+ )
+ if (label != null) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt
index 712e7a8..42b1e74 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt
@@ -91,7 +91,10 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
-import de.jeanlucmakiola.calendula.ui.common.beginsOn
+import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
+import de.jeanlucmakiola.calendula.ui.common.continuesAfter
+import de.jeanlucmakiola.calendula.ui.common.continuesBefore
+import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
@@ -104,6 +107,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
+import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
@@ -351,7 +355,7 @@ private fun DayContent(
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = { drop ->
- val took = move?.move(
+ move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
@@ -359,9 +363,6 @@ private fun DayContent(
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
- // Refused, so nothing will land: let the copy go now
- // rather than hold it out for a settle that never comes.
- if (took != true) dragController.release()
},
)
}
@@ -519,7 +520,7 @@ private fun AllDayBar(
val fill = eventFill(event.color, dark, soften)
Box(
modifier = modifier
- .background(fill, RoundedCornerShape(4.dp))
+ .background(fill, EventChipShape)
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
@@ -744,24 +745,30 @@ private fun EventBlock(
val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
- // A block clipped at the top continues from the previous day: its top edge
- // is midnight, not the event's start, so dragging it would invent a time.
- val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
+ val draggable = eventDragAllowed(block.event)
+ // The drop takes this offset back off, so a tail clipped at midnight lands
+ // where the event's own start belongs (#253).
+ val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
+ val shape = remember(block, date, zone) {
+ timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
+ }
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = block.event.instanceId,
- onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
+ onPickUp = { pointer, blockRoot, _ ->
+ dragController.begin(block, clipOffset, pointer, blockRoot)
+ },
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
)
- val lifted = draggable && dragController.ghosts(block, date)
+ val lifted = draggable && dragController.ghosts(block)
val ghost = ghostAlpha(lifted)
Box(
modifier = modifier
// The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
- .background(fill, RoundedCornerShape(4.dp))
+ .background(fill, shape)
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
index 4f36992..034935c 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
@@ -64,6 +64,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
@@ -264,13 +265,16 @@ fun EventEditScreen(
viewModel.reset()
onSaved()
}
+ // A failed save leaves the form looking unchanged, so the snackbar is
+ // the only sign anything happened — long rather than the default
+ // flash (Codeberg #234: it read as "nothing happens at all").
SaveUiState.Failed -> {
viewModel.consumeSaveResult()
- snackbarHostState.showSnackbar(saveFailedMessage)
+ snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long)
}
SaveUiState.NeedsPermission -> {
viewModel.consumeSaveResult()
- snackbarHostState.showSnackbar(writeDeniedMessage)
+ snackbarHostState.showSnackbar(writeDeniedMessage, duration = SnackbarDuration.Long)
}
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
else -> Unit
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt
index 2c3dc61..503db44 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.edit
+import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -56,6 +57,8 @@ import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
import javax.inject.Inject
+private const val TAG = "EventEdit"
+
/**
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
* want different reminder handling (#49), and differ in whether they own the
@@ -734,7 +737,12 @@ class EventEditViewModel @Inject constructor(
} else {
when (scope) {
RecurringWriteScope.ThisEvent ->
- repository.updateOccurrence(target.eventId, target.beginMillis, form)
+ repository.updateOccurrence(
+ target.eventId,
+ target.beginMillis,
+ target.original,
+ form,
+ )
RecurringWriteScope.ThisAndFollowing ->
repository.updateEventFromOccurrence(
eventId = target.eventId,
@@ -751,7 +759,15 @@ class EventEditViewModel @Inject constructor(
throw e
} catch (e: SecurityException) {
SaveUiState.NeedsPermission
+ } catch (e: NoSuchEventException) {
+ // The event or occurrence is already gone: the same answer the
+ // pre-check gives, and better than a bare "couldn't save".
+ SaveUiState.Gone
} catch (e: Exception) {
+ // The user only gets a generic snackbar, so without this a failed
+ // write leaves nothing to report (Codeberg #234). Scope and event
+ // id only — never the form's content.
+ Log.w(TAG, "Save failed (scope=$scope, eventId=${target?.eventId})", e)
SaveUiState.Failed
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt
index 56e6602..a3b7690 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt
@@ -23,6 +23,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.PriorityHigh
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -93,15 +94,11 @@ fun ImportScreen(
}
// Hoisted target calendar so the always-visible top-bar Import action can
- // read it without the user scrolling to a bottom button. Defaults to the
- // first *local* calendar — the first row the picker shows ("Your calendars"
- // group leads) — so the pre-selection lines up with the top of the list;
- // falls back to the first calendar if there are no local ones. Re-defaults
- // when the "many" list first arrives (keyed on it), then holds the pick.
+ // read it without the user scrolling to a bottom button (see
+ // [defaultImportTarget] for the choice). Re-defaults when the "many" list
+ // first arrives (keyed on it), then holds the pick.
val many = state as? ImportUiState.Many
- val defaultTarget = many?.calendars?.let { cals ->
- (cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
- }
+ val defaultTarget = many?.let { defaultImportTarget(it.calendars, it.fileCalendarName) }
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
Scaffold(
@@ -223,6 +220,10 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
)
}
+ // Part of the file never reached the calendar, so the screen doesn't get to
+ // read as a plain success.
+ val stopped = state.summary.notAttempted > 0
+
Column(
Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -236,22 +237,43 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
scaleY = badgeScale.value
}
.clip(CircleShape)
- .background(MaterialTheme.colorScheme.primaryContainer),
+ .background(
+ if (stopped) {
+ MaterialTheme.colorScheme.errorContainer
+ } else {
+ MaterialTheme.colorScheme.primaryContainer
+ },
+ ),
contentAlignment = Alignment.Center,
) {
Icon(
- Icons.Default.Check,
+ if (stopped) Icons.Default.PriorityHigh else Icons.Default.Check,
contentDescription = null,
- tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ tint = if (stopped) {
+ MaterialTheme.colorScheme.onErrorContainer
+ } else {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ },
modifier = Modifier.size(56.dp),
)
}
Spacer(Modifier.height(24.dp))
Text(
- stringResource(R.string.import_done_title),
+ stringResource(
+ if (stopped) R.string.import_done_stopped_title else R.string.import_done_title,
+ ),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
+ if (state.summary.failed > 0) {
+ Spacer(Modifier.height(8.dp))
+ Text(
+ stringResource(R.string.import_done_failed_note),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
if (state.summary.skippedDuplicate > 0) {
Spacer(Modifier.height(8.dp))
Text(
@@ -261,6 +283,21 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
textAlign = TextAlign.Center,
)
}
+ // The one note that isn't about individual events: the counts below cover
+ // the part of the file that was tried, so say how much never was.
+ if (stopped) {
+ Spacer(Modifier.height(8.dp))
+ Text(
+ pluralStringResource(
+ R.plurals.import_done_stopped,
+ state.summary.notAttempted,
+ state.summary.notAttempted,
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center,
+ )
+ }
Spacer(Modifier.height(24.dp))
Row(
Modifier.fillMaxWidth(),
@@ -290,6 +327,19 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
+ if (state.summary.failed > 0) {
+ ImportStatCard(
+ count = state.summary.failed,
+ label = stringResource(R.string.import_done_failed_label),
+ contentDescription = pluralStringResource(
+ R.plurals.import_done_failed,
+ state.summary.failed,
+ state.summary.failed,
+ ),
+ container = MaterialTheme.colorScheme.errorContainer,
+ onContainer = MaterialTheme.colorScheme.onErrorContainer,
+ )
+ }
}
Spacer(Modifier.weight(1f))
Button(
@@ -301,7 +351,7 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
}
}
-/** A big-number tonal tile summarising one import outcome (added / skipped). */
+/** A big-number tonal tile summarising one import outcome (added / skipped / failed). */
@Composable
private fun RowScope.ImportStatCard(
count: Int,
@@ -343,6 +393,9 @@ private fun WarningText(warning: IcsParseWarning) {
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
+ IcsParseWarning.TasksImportedAsEvents -> stringResource(R.string.import_warning_tasks)
+ IcsParseWarning.RecurrenceRuleRepaired ->
+ stringResource(R.string.import_warning_recurrence_repaired)
}
Text(
text = text,
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt
index 723132b..46a18a2 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt
@@ -43,6 +43,12 @@ sealed interface ImportUiState {
val events: List,
val warnings: Set,
val calendars: List,
+ /**
+ * The calendar the file says its events came from, when every one of
+ * them names the same — used to preselect a matching target (see
+ * [fileCalendarName]).
+ */
+ val fileCalendarName: String? = null,
) : ImportUiState
data class Done(val summary: IcsImportSummary) : ImportUiState
@@ -92,6 +98,7 @@ class ImportViewModel @Inject constructor(
warnings = parsed.warnings,
calendars = repository.calendars().first()
.filter { it.isEventTarget },
+ fileCalendarName = fileCalendarName(parsed.events),
)
}
}
@@ -113,3 +120,33 @@ class ImportViewModel @Inject constructor(
}
}
}
+
+/**
+ * The one calendar [events] all came from, or null.
+ *
+ * Every event has to agree, unnamed ones included: a foreign file where a single
+ * `VEVENT` carries a `CATEGORIES` tag and the rest carry nothing names no
+ * calendar — it just has one tagged event, and letting it pick the target would
+ * land the whole file wherever that tag happens to match.
+ */
+internal fun fileCalendarName(events: List): String? =
+ events.map { it.calendarName }.distinct().singleOrNull()
+
+/**
+ * The target calendar to preselect for a bulk import.
+ *
+ * A calendar named like the one the file came from wins — restoring "Birthdays"
+ * onto the Birthdays calendar is what the user means. Otherwise the first
+ * *local* calendar, which is the first row the picker shows ("Your calendars"
+ * leads), so the pre-selection lines up with the top of the list; failing that
+ * the first calendar of any kind.
+ */
+internal fun defaultImportTarget(
+ calendars: List,
+ fileCalendarName: String?,
+): Long? {
+ val named = fileCalendarName?.let { name ->
+ calendars.firstOrNull { it.displayName.equals(name, ignoreCase = true) }
+ }
+ return (named ?: calendars.firstOrNull { it.isLocal } ?: calendars.firstOrNull())?.id
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt
index f3dcf3c..809a618 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt
@@ -13,6 +13,8 @@ import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.IntSize
import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.LocalDate
import kotlin.math.abs
@@ -33,6 +35,8 @@ class MonthRowGeometry(
val isRtl: Boolean,
/** What this row currently draws in [lane] of column `col`, or null. */
val chipAt: (col: Int, lane: Int) -> EventInstance?,
+ /** The column the chip in [lane] of column `col` is drawn from. */
+ val chipStart: (col: Int, lane: Int) -> Int,
) {
/** Root top-left of the chip seated in [lane] of column [col]. */
fun seat(col: Int, lane: Int): Offset? {
@@ -47,21 +51,43 @@ class MonthRowGeometry(
* the day isn't in this week, or the chip went into the day's "+N" overflow.
*/
fun seatOf(event: EventInstance, date: LocalDate): Offset? {
- val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
- val lane = (0 until laneCount).firstOrNull { lane ->
- chipAt(col, lane)?.let { isSameEvent(it, event) } == true
- } ?: return null
+ val (col, lane) = chipSeat(days, laneCount, chipAt, chipStart, event, date) ?: return null
return seat(col, lane)
}
}
+/**
+ * The column and lane a row draws [event]'s chip in, given it covers [date] —
+ * null when the row seats no such chip. The column is the *left end* of the bar
+ * within this row, not [date]'s own: a bar spanning several days is drawn once,
+ * from its first column, so an event dropped onto the second day it covers is
+ * seated a column (or more) to the left of where it landed (#253).
+ */
+internal fun chipSeat(
+ days: List,
+ laneCount: Int,
+ chipAt: (col: Int, lane: Int) -> EventInstance?,
+ chipStart: (col: Int, lane: Int) -> Int,
+ event: EventInstance,
+ date: LocalDate,
+): Pair? {
+ val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
+ val lane = (0 until laneCount).firstOrNull { lane ->
+ chipAt(col, lane)?.let { isSameEvent(it, event) } == true
+ } ?: return null
+ return chipStart(col, lane) to lane
+}
+
/**
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither id
* alone will do: re-read instances get new instance ids, and a single-occurrence
- * move writes a new event id — the title survives both.
+ * move writes a new event id — the title survives both. A *blank* title is no
+ * evidence at all, though: every untitled event carries one, so matching on it
+ * made any two of them the same event (#253).
*/
-private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
- chip.eventId == moved.eventId || chip.title == moved.title
+internal fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
+ chip.eventId == moved.eventId ||
+ (moved.title.isNotBlank() && chip.title == moved.title)
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */
data class MonthChipDrag(
@@ -134,6 +160,16 @@ class MonthDragController {
*/
private var undoable: MonthChipDrag? = null
+ /**
+ * The abandoned-move counter, read at the drop so the mark can't be a
+ * composition behind the tick it is compared against.
+ */
+ var abandonedTicks: StateFlow? = null
+
+ /** What [abandonedTicks] stood at when the settling chip was let go. */
+ var settledAbandonedMark: Int = 0
+ private set
+
private var event: EventInstance? = null
private var grabDate: LocalDate? = null
private var grab = Offset.Zero
@@ -222,6 +258,7 @@ class MonthDragController {
liftedInstanceId = last.event.instanceId
settledGhost = true
settledInRoot = null
+ settledAbandonedMark = abandonedTicks?.value ?: 0
return true
}
@@ -242,6 +279,7 @@ class MonthDragController {
liftedInstanceId = landed.event.instanceId
settledGhost = true
undoable = settling
+ settledAbandonedMark = abandonedTicks?.value ?: 0
return MonthChipDrop(landed.event, landed.grabDate, target)
}
@@ -298,6 +336,10 @@ class MonthDragController {
}
@Composable
-fun rememberMonthDragController(): MonthDragController = remember { MonthDragController() }
+fun rememberMonthDragController(): MonthDragController {
+ val controller = remember { MonthDragController() }
+ controller.abandonedTicks = LocalEventMove.current?.abandoned
+ return controller
+}
val LocalMonthDrag = compositionLocalOf { null }
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt
index a929d87..2a510f2 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt
@@ -95,6 +95,7 @@ import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
+import de.jeanlucmakiola.calendula.ui.common.abandonedMoves
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
@@ -145,6 +146,8 @@ import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
+import de.jeanlucmakiola.calendula.ui.common.EventChipShape
+import de.jeanlucmakiola.calendula.ui.common.monthBarShape
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
@@ -505,11 +508,14 @@ private fun MonthDragOverlay(controller: MonthDragController) {
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec()
val seat = controller.settledInRoot
- LaunchedEffect(controller.settling, moveInFlight, seat) {
+ val abandoned = abandonedMoves()
+ LaunchedEffect(controller.settling, moveInFlight, seat, abandoned) {
val landed = controller.settling
if (landed == null || moveInFlight) return@LaunchedEffect
if (seat == null) {
- delay(MONTH_SETTLE_TIMEOUT_MILLIS)
+ // Past the mark this drop was let go on, it was abandoned and no
+ // seat is ever coming.
+ if (abandoned == controller.settledAbandonedMark) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
} else {
if (!gliding) glide.snapTo(landed.topLeftInRoot)
gliding = true
@@ -563,7 +569,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
scaleY = 1f + 0.04f * lift
shadowElevation = 8.dp.toPx() * lift
alpha = copyAlpha
- shape = RoundedCornerShape(4.dp)
+ shape = EventChipShape
clip = false
},
)
@@ -1919,6 +1925,7 @@ private fun MonthWeekRow(
laneCount = MAX_EVENT_ROWS,
isRtl = isRtl,
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
+ chipStart = { col, lane -> week.chipStartCol(col, lane) },
),
)
}
@@ -2310,7 +2317,7 @@ private fun monthChipDragModifier(
// How many columns the finger crossed — grabbing the middle of a
// multi-day bar shifts by what it travelled, not to where it landed.
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
- val took = moveScope?.move(
+ moveScope?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
@@ -2318,9 +2325,6 @@ private fun monthChipDragModifier(
target = MoveTarget.ByDays(delta),
),
)
- // Refused, so nothing will land: let the copy go now rather than
- // hold the chip out for a settle that can never arrive.
- if (took != true) controller?.release()
}
},
onCancel = { controller?.cancel() },
@@ -2436,12 +2440,7 @@ private fun MonthBar(
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
monthDrag?.isSettledChip(event, days) == true
val ghost = ghostAlpha(lifted)
- val shape = RoundedCornerShape(
- topStart = if (continuesLeft) 0.dp else 4.dp,
- bottomStart = if (continuesLeft) 0.dp else 4.dp,
- topEnd = if (continuesRight) 0.dp else 4.dp,
- bottomEnd = if (continuesRight) 0.dp else 4.dp,
- )
+ val shape = monthBarShape(continuesLeft, continuesRight)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt
index 56624d4..434760e 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt
@@ -84,6 +84,16 @@ fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
}
+/**
+ * The column the chip drawn in lane [lane] of column [col] begins in. A bar is
+ * drawn once, from its own first column, so a chip seated by [chipAt] on the
+ * second day it covers belongs a column (or more) to the left (#253). Answers
+ * [col] for a single-day chip, and for an empty slot no one should be asking
+ * about.
+ */
+fun MonthWeek.chipStartCol(col: Int, lane: Int): Int =
+ spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.startCol ?: col
+
/**
* The events on [day] that [laneEvents] had no lane left for — its exact
* complement, in the same bars-then-pills order. Returned as events rather than
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt
index c4cab41..f15345a 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt
@@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
+import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -417,8 +418,11 @@ internal fun layoutCalendarWeek(
zone: TimeZone,
): MonthWeek {
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
+ // A bar is decided by the *event*, not by how much of it this row holds: one
+ // crossing midnight into the next week row covers a single day in each, and
+ // counting per row drew it as two unrelated pills (#253).
val (bars, singles) = weekEvents.partition { ev ->
- ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
+ ev.isAllDay || ev.spansMultipleDays(zone)
}
val spans = layoutAllDay(bars, days, zone).map { s ->
MonthSpan(
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt
index 10b3fd1..6128fb8 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt
@@ -100,7 +100,10 @@ import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
-import de.jeanlucmakiola.calendula.ui.common.beginsOn
+import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
+import de.jeanlucmakiola.calendula.ui.common.continuesAfter
+import de.jeanlucmakiola.calendula.ui.common.continuesBefore
+import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
@@ -108,6 +111,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
+import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.NowLine
@@ -387,7 +391,7 @@ private fun WeekContent(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = { drop ->
- val took = move?.move(
+ move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
@@ -395,9 +399,6 @@ private fun WeekContent(
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
- // Refused, so nothing will land: let the copy go now
- // rather than hold it out for a settle that never comes.
- if (took != true) dragController.release()
},
)
}
@@ -656,7 +657,7 @@ private fun AllDayBar(
val fill = eventFill(event.color, dark, soften)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
- .background(fill, RoundedCornerShape(4.dp))
+ .background(fill, EventChipShape)
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
@@ -912,24 +913,30 @@ private fun EventBlock(
val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
- // A block clipped at the top continues from the previous day: its top edge
- // is midnight, not the event's start, so dragging it would invent a time.
- val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
+ val draggable = eventDragAllowed(block.event)
+ // The drop takes this offset back off, so a tail clipped at midnight lands
+ // where the event's own start belongs (#253).
+ val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
+ val shape = remember(block, date, zone) {
+ timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
+ }
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = block.event.instanceId,
- onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
+ onPickUp = { pointer, blockRoot, _ ->
+ dragController.begin(block, clipOffset, pointer, blockRoot)
+ },
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
)
- val lifted = draggable && dragController.ghosts(block, date)
+ val lifted = draggable && dragController.ghosts(block)
val ghost = ghostAlpha(lifted)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
// The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
- .background(fill, RoundedCornerShape(4.dp))
+ .background(fill, shape)
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt
index 71f603c..81a3b49 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt
@@ -206,6 +206,17 @@ internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
return start < dayEnd && end > dayStart
}
+/**
+ * True if this event touches more than one calendar day in [zone] — an all-day
+ * event covering a range, or a timed one crossing midnight. Occurrences are
+ * contiguous, so covering the day after the first is the whole question.
+ */
+internal fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean {
+ val anchor = if (isAllDay) TimeZone.UTC else zone
+ val secondDay = start.toLocalDateTime(anchor).date.plus(1, DateTimeUnit.DAY)
+ return coversDay(secondDay, zone)
+}
+
/**
* Clip [events] to a single [day] and assign lanes so overlapping events render
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt
new file mode 100644
index 0000000..b08c987
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt
@@ -0,0 +1,115 @@
+package de.jeanlucmakiola.calendula.widget
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.getSystemService
+import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.atStartOfDayIn
+import kotlinx.datetime.plus
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.Instant
+
+/**
+ * Holds the app's own wake-up for the next local midnight, so the home-screen
+ * widgets roll "today" over on the day boundary (#228).
+ *
+ * The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted
+ * implicit broadcast — a manifest-declared receiver has not been given it since
+ * Android 8, leaving only the throttled `updatePeriodMillis`.
+ *
+ * Exactly one alarm exists at a time and every firing re-arms the next, the same
+ * shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
+ * Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
+ * survives doze (plain `set` does not), a rollover a few minutes late is
+ * invisible on a sleeping screen, and exact alarms stay reserved for snooze.
+ */
+object WidgetRolloverScheduler {
+
+ /**
+ * Fire just *after* midnight: an alarm delivered a few milliseconds early
+ * would still read the old date and re-arm for an instant later.
+ */
+ internal val ROLLOVER_SLACK = 5.seconds
+
+ /**
+ * Arm the next rollover, or cancel a pending one when no widget is placed.
+ * Idempotent, so every trigger (boot, app start, widget added/removed, the
+ * rollover itself, a clock or timezone change) can just call it.
+ */
+ fun sync(context: Context) {
+ val appContext = context.applicationContext
+ val alarmManager = appContext.getSystemService() ?: return
+ val pendingIntent = rolloverPendingIntent(appContext)
+ if (!hasPlacedWidgets(appContext)) {
+ alarmManager.cancel(pendingIntent)
+ return
+ }
+ val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
+ alarmManager.setAndAllowWhileIdle(
+ AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
+ )
+ }
+
+ /**
+ * The instant just after the next local midnight following [now] in [zone].
+ *
+ * The *actual* start of day, not 00:00, so it holds where a DST jump means
+ * midnight never happens (Havana) and where a date-line move skips a whole
+ * local date (Apia, December 2011) — the loop walks on to the next real day.
+ *
+ * The mirror case — a zone rewinding *across* midnight, so the day starts
+ * twice — resolves to the earlier start and runs an hour ahead of the clock.
+ * No live tz entry does that (Brazil dropped DST in 2019) and
+ * `updatePeriodMillis` covers it, so it isn't worth state to detect.
+ */
+ fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
+ val date = now.toLocalDateTime(zone).date
+ var days = 1
+ while (days <= MAX_LOOKAHEAD_DAYS) {
+ val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
+ if (candidate > now) return candidate
+ days++
+ }
+ // Unreachable for any zone in the tz database. Deliberately an hour and
+ // not the slack: a 5-second retry would just hit this branch again and
+ // wake the device in a loop.
+ return now + 1.hours
+ }
+
+ private fun hasPlacedWidgets(context: Context): Boolean {
+ val manager = AppWidgetManager.getInstance(context) ?: return false
+ return PROVIDERS.any {
+ manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty()
+ }
+ }
+
+ private fun rolloverPendingIntent(context: Context): PendingIntent =
+ PendingIntent.getBroadcast(
+ context,
+ ROLLOVER_REQUEST_CODE,
+ Intent(context, WidgetUpdateReceiver::class.java)
+ .setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ private val PROVIDERS = listOf(
+ MonthWidgetReceiver::class.java,
+ AgendaWidgetReceiver::class.java,
+ )
+
+ /** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
+ private const val ROLLOVER_REQUEST_CODE = 0x0DA1
+
+ /** A gap of more than a couple of days does not exist in any tz database entry. */
+ private const val MAX_LOOKAHEAD_DAYS = 3
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt
index 18d5e81..b4972aa 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.widget
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
+import android.os.SystemClock
import androidx.glance.appwidget.updateAll
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
@@ -10,32 +11,110 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
+import java.util.concurrent.atomic.AtomicLong
/**
- * Redraws both home-screen widgets when their data goes stale. Triggered by:
+ * Redraws both home-screen widgets when their data goes stale, and keeps the
+ * midnight rollover alarm armed. Triggered by:
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
* so it covers both the app's own writes and external sync.
- * - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
- * and the upcoming window roll over at midnight / on a clock change.
+ * - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] —
+ * the day boundary, so "today" highlighting and the agenda's past-event
+ * dimming move on (#228).
+ * - `TIME_SET` / `TIMEZONE_CHANGED` — the day boundary moved, so redraw *and*
+ * re-arm.
+ * - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the
+ * latter is also what arms installs upgrading into the fix.
*
- * Both widgets also carry an `updatePeriodMillis` backstop in their provider
- * XML, and the month widget's refresh button forces an immediate redraw.
+ * `DATE_CHANGED` rides along as a free extra the system may or may not send —
+ * see [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
+ * provider XML and the month widget's refresh button.
+ *
+ * Every action that dropped, consumed or invalidated the alarm re-arms it, and
+ * `PROVIDER_CHANGED` re-arms at most once every [DATA_REARM_INTERVAL_MILLIS]
+ * ([dataRearmDue]). The alarm is inexact, so the system may drop it outright in
+ * a restricted standby bucket; nothing in ordinary daily use would then notice,
+ * and the widget sits on yesterday's date. A calendar change is the one signal
+ * that keeps arriving — but it arrives on *every* write (each drag, each event
+ * of a thousand-event import), which is what the interval is for.
+ *
+ * Exported for the system broadcasts; an extra redraw from another app is
+ * harmless.
*/
class WidgetUpdateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
- val pending = goAsync()
+ // Exported, so anything can reach it with an explicit intent. Nothing
+ // here crosses a trust boundary, but narrowing to the actions we asked
+ // for keeps a stray broadcast from costing two wide provider reads.
+ if (intent.action !in HANDLED_ACTIONS) return
val appContext = context.applicationContext
+ val rearm = intent.action in REARM_ACTIONS ||
+ (intent.action == Intent.ACTION_PROVIDER_CHANGED && dataRearmDue())
+ // The host sends APPWIDGET_UPDATE after the re-arm-only ones anyway, so
+ // redrawing there would only repeat the work in a cold process, at the
+ // moment the device is most contended.
+ val redraw = intent.action !in REARM_ONLY_ACTIONS
+ val pending = goAsync()
// Calendar data may have changed (sync / our own write) — drop the cached
// month window so the widgets reload fresh. Month paging does NOT call
// this, so arrow taps stay instant.
- invalidateMonthWidgetCache()
+ if (redraw) invalidateMonthWidgetCache()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
- AgendaWidget().updateAll(appContext)
- MonthWidget().updateAll(appContext)
+ // Re-armed first, so the next day boundary is covered whatever
+ // the redraw does — and off the receiving thread, since it is a
+ // handful of binder calls (`CalendulaApp` moves it for the same
+ // reason).
+ if (rearm) WidgetRolloverScheduler.sync(appContext)
+ if (redraw) {
+ AgendaWidget().updateAll(appContext)
+ MonthWidget().updateAll(appContext)
+ }
} finally {
pending.finish()
}
}
}
+
+ companion object {
+ /** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
+ const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
+
+ /** Both wipe pending alarms, and the host redraws the widgets itself after them. */
+ private val REARM_ONLY_ACTIONS = setOf(
+ Intent.ACTION_BOOT_COMPLETED,
+ Intent.ACTION_MY_PACKAGE_REPLACED,
+ )
+
+ /**
+ * The actions that leave no alarm standing: the two above wipe it, the
+ * rollover consumed itself, and a clock, zone or day change moved the
+ * boundary the pending one was set for.
+ */
+ internal val REARM_ACTIONS = REARM_ONLY_ACTIONS + setOf(
+ ACTION_ROLLOVER,
+ Intent.ACTION_TIME_CHANGED,
+ Intent.ACTION_TIMEZONE_CHANGED,
+ Intent.ACTION_DATE_CHANGED,
+ )
+
+ internal val HANDLED_ACTIONS = REARM_ACTIONS + Intent.ACTION_PROVIDER_CHANGED
+
+ /** How rarely a calendar change may re-arm; see the class doc. */
+ private const val DATA_REARM_INTERVAL_MILLIS = 15 * 60_000L
+
+ /** Uptime of the last data-change re-arm, 0 for none in this process. */
+ private val lastDataRearmMillis = AtomicLong(0L)
+
+ /**
+ * Whether a calendar change may re-arm now — the backstop for an alarm
+ * the system dropped, throttled so a burst of writes costs one pass.
+ */
+ private fun dataRearmDue(): Boolean {
+ val now = SystemClock.elapsedRealtime()
+ val last = lastDataRearmMillis.get()
+ val due = last == 0L || now - last >= DATA_REARM_INTERVAL_MILLIS
+ return due && lastDataRearmMillis.compareAndSet(last, now)
+ }
+ }
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt
index 4e1b088..736da00 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt
@@ -1,7 +1,10 @@
package de.jeanlucmakiola.calendula.widget.agenda
+import android.appwidget.AppWidgetManager
+import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
/**
* Host-facing receiver for the agenda widget. Declared in the manifest with the
@@ -10,4 +13,32 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
*/
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
+
+ /** First agenda widget placed — start rolling "today" over at midnight (#228). */
+ override fun onEnabled(context: Context) {
+ super.onEnabled(context)
+ WidgetRolloverScheduler.sync(context)
+ }
+
+ /**
+ * Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if
+ * no month widget is left either.
+ */
+ override fun onDisabled(context: Context) {
+ super.onDisabled(context)
+ WidgetRolloverScheduler.sync(context)
+ }
+
+ /**
+ * Self-heal on the system's own `updatePeriodMillis` wake-up — see
+ * [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
+ */
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray,
+ ) {
+ super.onUpdate(context, appWidgetManager, appWidgetIds)
+ WidgetRolloverScheduler.sync(context)
+ }
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt
index 6778bda..353db0c 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt
@@ -152,7 +152,17 @@ class ShiftMonthAction : ActionCallback {
val delta = parameters[deltaKey] ?: 0
updateAppWidgetState(context, glanceId) { prefs ->
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
- prefs[MONTH_INDEX_KEY] = cur + delta
+ val next = cur + delta
+ // Landing back on the current month clears the key, so the widget
+ // goes back to *following* the date rather than being pinned to
+ // whichever month was current at the tap. Paging out and back is the
+ // workaround #228's reporter used, and pinning it there would have
+ // stuck them on that month once it stopped being the current one.
+ if (next == currentMonthIndex(systemZone())) {
+ prefs.remove(MONTH_INDEX_KEY)
+ } else {
+ prefs[MONTH_INDEX_KEY] = next
+ }
}
MonthWidget().update(context.applicationContext, glanceId)
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt
index e450f0c..08e4a3e 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt
@@ -1,7 +1,10 @@
package de.jeanlucmakiola.calendula.widget.month
+import android.appwidget.AppWidgetManager
+import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
/**
* Host-facing receiver for the month widget. Declared in the manifest with the
@@ -9,4 +12,35 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
*/
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
+
+ /** First month widget placed — start rolling "today" over at midnight (#228). */
+ override fun onEnabled(context: Context) {
+ super.onEnabled(context)
+ WidgetRolloverScheduler.sync(context)
+ }
+
+ /**
+ * Last month widget removed. [WidgetRolloverScheduler.sync] cancels only if
+ * no agenda widget is left either.
+ */
+ override fun onDisabled(context: Context) {
+ super.onDisabled(context)
+ WidgetRolloverScheduler.sync(context)
+ }
+
+ /**
+ * The `updatePeriodMillis` backstop is the one wake-up the *system* still
+ * owns, so it doubles as the alarm's self-heal: anything that drops a
+ * pending alarm without a broadcast (force-stop, battery restriction, an OEM
+ * freeze) is repaired here rather than on the next app open. Re-arming
+ * closer to midnight also narrows the inexact delivery window.
+ */
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray,
+ ) {
+ super.onUpdate(context, appWidgetManager, appWidgetIds)
+ WidgetRolloverScheduler.sync(context)
+ }
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d8c06b4..db87d9c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -231,6 +231,8 @@
Your response: %1$s
At time of event
Default reminder
+
+ %1$s after
- %d minute before
- %d minutes before
@@ -693,11 +695,16 @@
Events already in the calendar were skipped.
Added
Duplicates
+ Not added
+ Some events couldn\'t be added and were left out.
+ Import stopped
Close
Some changed occurrences of recurring events were skipped.
An event without a start time was skipped.
Guest lists weren\'t imported.
An unknown time zone fell back to your device\'s.
+ Tasks in this file were added as events.
+ A faulty repeat rule was repaired.
Import
- Importing %d event
@@ -711,6 +718,10 @@
- Import %d event
- Import %d events
+
+ - The calendar stopped accepting events part way through. %d event from the file was never added.
+ - The calendar stopped accepting events part way through. %d events from the file were never added.
+
- Imported %d event.
- Imported %d events.
@@ -719,6 +730,10 @@
- Skipped %d already in this calendar.
- Skipped %d already in this calendar.
+
+ - %d event couldn\'t be added.
+ - %d events couldn\'t be added.
+
New event
Create a new event
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
index 18659b8..8eeef6a 100644
--- a/app/src/main/res/xml/locales_config.xml
+++ b/app/src/main/res/xml/locales_config.xml
@@ -10,14 +10,17 @@
+
+
+
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt
index 0dd4cc4..75da13d 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt
@@ -143,6 +143,76 @@ class AllDayReminderEncodingTest {
assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct
}
+ @Test
+ fun `an imported yearly series samples at its next occurrence, not its anchor`() {
+ // Fossify anchors a year-less contact birthday at 1970, when Berlin had
+ // no DST; sampling there fires a modern June occurrence at 10:00 instead
+ // of the 09:00 the user picked (Codeberg #225).
+ val anchor = LocalDate.of(1970, 6, 3)
+ val today = LocalDate.of(2026, 6, 1)
+
+ val sampled = importedAllDayReminderDate(anchor, "FREQ=YEARLY;BYMONTH=6", today)
+ assertThat(sampled).isEqualTo(LocalDate.of(2026, 6, 3))
+
+ val raw = toProviderAllDayMinutes(0, sampled, berlin, nineAm)
+ val fire = actualFire(raw, LocalDate.of(2027, 6, 3))
+ .let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
+ assertThat(fire).isEqualTo(LocalTime.of(9, 0))
+ }
+
+ @Test
+ fun `the lead time still reads back from the anchor's own date`() {
+ // The offset is sampled at the next occurrence but decoded against
+ // DTSTART, so the two must still agree on the whole-day lead time.
+ val anchor = LocalDate.of(1970, 6, 3)
+ val sampled = importedAllDayReminderDate(anchor, "FREQ=YEARLY", LocalDate.of(2026, 6, 1))
+
+ for (semantic in listOf(0, 1_440, 2_880)) {
+ val raw = toProviderAllDayMinutes(semantic, sampled, berlin, nineAm)
+ assertThat(fromProviderAllDayMinutes(raw, anchor, berlin, nineAm)).isEqualTo(semantic)
+ }
+ }
+
+ @Test
+ fun `a one-off import samples at its own date, a stale series at today`() {
+ val past = LocalDate.of(1970, 6, 3)
+ val today = LocalDate.of(2026, 6, 1)
+ val future = LocalDate.of(2027, 3, 4)
+
+ assertThat(importedAllDayReminderDate(past, null, today)).isEqualTo(past)
+ assertThat(importedAllDayReminderDate(past, "FREQ=WEEKLY;BYDAY=MO", today)).isEqualTo(today)
+ assertThat(importedAllDayReminderDate(future, "FREQ=WEEKLY", today)).isEqualTo(future)
+ }
+
+ @Test
+ fun `the provider default sentinel survives a round trip untouched`() {
+ // -1 is Reminders.MINUTES_DEFAULT, not an offset. Decoded it landed on
+ // the start date and came back as 0 — a concrete at-start alarm on the
+ // detail screen and in an exported file.
+ assertThat(toProviderAllDayMinutes(-1, winter, berlin, nineAm)).isEqualTo(-1)
+ assertThat(fromProviderAllDayMinutes(-1, winter, berlin, nineAm)).isEqualTo(-1)
+ }
+
+ @Test
+ fun `a real encoding that is negative is still decoded`() {
+ // "At time of event" encodes negative — the sentinel check must be the
+ // exact value, not any negative one.
+ val raw = toProviderAllDayMinutes(0, winter, berlin, nineAm)
+ assertThat(raw).isLessThan(0)
+ assertThat(fromProviderAllDayMinutes(raw, winter, berlin, nineAm)).isEqualTo(0)
+ }
+
+ @Test
+ fun `an encoding that would land on the sentinel is nudged off it`() {
+ // 01:01 Berlin in winter (CET, +1) is 00:01 UTC — one minute past the
+ // event's UTC-midnight DTSTART, i.e. exactly -1. Left there it would read
+ // back as "use the account default" and the real reminder would be gone
+ // from the screen and from an export.
+ val raw = toProviderAllDayMinutes(0, winter, berlin, 61)
+ assertThat(raw).isEqualTo(-2)
+ assertThat(fromProviderAllDayMinutes(raw, winter, berlin, 61)).isEqualTo(0)
+ }
+
@Test
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
// Known limitation: one fixed MINUTES per series can't track DST. An
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt
index 1e022a6..f67f674 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt
@@ -22,6 +22,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlin.time.Instant
import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
@@ -642,7 +643,12 @@ class CalendarRepositoryImplTest {
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
)
- val id = repo.updateOccurrence(eventId = 42L, beginMillis = 1_000L, form = form)
+ val id = repo.updateOccurrence(
+ eventId = 42L,
+ beginMillis = 1_000L,
+ original = form,
+ form = form,
+ )
assertThat(id).isEqualTo(88L)
assertThat(fake.updatedOccurrences).containsExactly(Triple(42L, 1_000L, form))
@@ -801,6 +807,113 @@ class CalendarRepositoryImplTest {
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
}
+ @Test
+ fun `importEvents carries on past an event the provider rejects`(
+ @TempDir tempDir: Path,
+ ) = runTest {
+ // A foreign export can hold a row the provider throws on outright; that
+ // must cost the user one event, not the whole file (Codeberg #225).
+ val fake = FakeCalendarDataSource().apply { failingImportSummaries += "bad" }
+ val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
+ val events = listOf(
+ parsedEvent("a@x", summary = "good"),
+ parsedEvent("b@x", summary = "bad"),
+ parsedEvent("c@x", summary = "good"),
+ )
+
+ val summary = repo.importEvents(targetCalendarId = 3L, events = events)
+
+ assertThat(summary.imported).isEqualTo(2)
+ assertThat(summary.failed).isEqualTo(1)
+ assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("a@x", "c@x")
+ }
+
+ @Test
+ fun `importEvents stops at a permission failure and reports what landed`(
+ @TempDir tempDir: Path,
+ ) = runTest {
+ // The permission or the target calendar is gone, so every remaining insert
+ // would fail the same way. Counted per event, the screen reported "N events
+ // couldn't be added" for a file it never had a chance at; thrown, it
+ // reported that nothing landed — and since only events carrying a UID dedup,
+ // the retry that invites duplicated everything that already had.
+ val fake = FakeCalendarDataSource().apply {
+ importsBeforeError = 2
+ writeError = SecurityException("WRITE_CALENDAR revoked")
+ }
+ val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
+
+ val summary = repo.importEvents(
+ targetCalendarId = 3L,
+ events = List(5) { parsedEvent("e$it@x") },
+ )
+
+ assertThat(summary.imported).isEqualTo(2)
+ // The one that threw and the two never reached.
+ assertThat(summary.notAttempted).isEqualTo(3)
+ assertThat(summary.failed).isEqualTo(0)
+ }
+
+ @Test
+ fun `a stopped import does not count known duplicates as work left to do`(
+ @TempDir tempDir: Path,
+ ) = runTest {
+ // The remainder is not all work: a UID already in the calendar would have
+ // been skipped whatever happened, so counting it against a retry
+ // overstates how much of the file is missing.
+ val fake = FakeCalendarDataSource().apply {
+ existingUidsResult = setOf("e3@x", "e4@x")
+ importsBeforeError = 1
+ writeError = SecurityException("WRITE_CALENDAR revoked")
+ }
+ val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
+
+ val summary = repo.importEvents(
+ targetCalendarId = 3L,
+ events = List(5) { parsedEvent("e$it@x") },
+ )
+
+ assertThat(summary.imported).isEqualTo(1)
+ // e1 threw and e2 was never reached; e3 and e4 were duplicates either way.
+ assertThat(summary.notAttempted).isEqualTo(2)
+ assertThat(summary.skippedDuplicate).isEqualTo(2)
+ }
+
+ @Test
+ fun `importEvents looks the target palette up once, not per event`(
+ @TempDir tempDir: Path,
+ ) = runTest {
+ var lookups = 0
+ val fake = FakeCalendarDataSource().apply {
+ publishedEventColorsResult = { lookups++; emptyList() }
+ }
+ val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
+
+ repo.importEvents(3L, List(5) { parsedEvent("e$it@x") })
+
+ assertThat(lookups).isEqualTo(1)
+ }
+
+ @Test
+ fun `importEvents matches colours against the uncurated palette`(
+ @TempDir tempDir: Path,
+ ) = runTest {
+ // Curation is a picker concession — it folds look-alikes and drops the
+ // neutrals from an oversized palette — but every published key is one
+ // the calendar accepts, so an imported colour matches against all of
+ // them (Codeberg #225).
+ val published = listOf(EventColorOption("1", 0xFF808080.toInt()))
+ val fake = FakeCalendarDataSource().apply {
+ publishedEventColorsResult = { published }
+ eventColorPaletteResult = { emptyList() }
+ }
+ val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
+
+ repo.importEvents(3L, listOf(parsedEvent("a@x")))
+
+ assertThat(fake.lastImportPalette).isEqualTo(published)
+ }
+
@Test
fun `exportEvents forwards the chosen calendar-id subset to the data source`(
@TempDir tempDir: Path,
@@ -825,9 +938,10 @@ class CalendarRepositoryImplTest {
assertThat(fake.lastExportableEventsCalendarIds).isNull()
}
- private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
+ private fun parsedEvent(uid: String?, summary: String = "E") =
+ de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
uid = uid,
- summary = "E",
+ summary = summary,
start = Instant.fromEpochMilliseconds(1_000_000_000L),
end = Instant.fromEpochMilliseconds(1_000_003_600L),
isAllDay = false,
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt
index 3cb50a8..c369cf6 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt
@@ -498,6 +498,7 @@ class EventWriteMapperTest {
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
+ rdate = null,
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
@@ -521,6 +522,7 @@ class EventWriteMapperTest {
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
+ rdate = null,
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
@@ -536,6 +538,7 @@ class EventWriteMapperTest {
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
+ rdate = null,
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
@@ -543,6 +546,27 @@ class EventWriteMapperTest {
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
}
+ @Test
+ fun `an all-day drop folds away a padded repeat of the same day`() {
+ // Compared verbatim, the two spellings of 15 July are different strings:
+ // the day ends up excluded twice, and exdateContains — the guard against
+ // detaching the same occurrence twice — misses it entirely.
+ val occurrence = 1_784_073_600_000L // 2026-07-15T00:00:00Z
+ val values = buildOccurrenceExdateValues(
+ existingExdate = "20260715T000000Z",
+ occurrenceMillis = occurrence,
+ dtStartMillis = 1_783_900_800_000L,
+ rrule = "FREQ=WEEKLY",
+ rdate = null,
+ duration = "P1D",
+ timezone = "UTC",
+ allDay = 1,
+ )
+ assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715")
+ assertThat(exdateContains("20260715T000000Z", occurrence, isAllDay = true, timezone = null))
+ .isTrue()
+ }
+
@Test
fun `all-day exdate drop uses the date-only form`() {
// An all-day DTSTART sits at UTC midnight, so the date reads off UTC.
@@ -551,6 +575,7 @@ class EventWriteMapperTest {
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
dtStartMillis = 1_783_900_800_000L,
rrule = "FREQ=YEARLY",
+ rdate = null,
duration = "P1D",
timezone = "UTC",
allDay = 1,
@@ -559,6 +584,90 @@ class EventWriteMapperTest {
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
}
+ @Test
+ fun `an exdate list zoned the way the provider writes it is read in that zone`() {
+ // AOSP's RecurrenceSet keeps the TZID parameter's *value*, not the
+ // parameter: `Europe/Berlin;`, no `TZID=`. Matching only the
+ // iCalendar spelling read every list the provider itself wrote as if its
+ // floating stamps were UTC — hours off for any zone but UTC.
+ assertThat(exdateListZone("Europe/Berlin;20260708T100000", "UTC"))
+ .isEqualTo(ZoneId.of("Europe/Berlin"))
+ assertThat(exdateListZone("TZID=Europe/Berlin;20260708T100000", "UTC"))
+ .isEqualTo(ZoneId.of("Europe/Berlin"))
+ // No prefix, or one this device can't place: the row's own zone.
+ assertThat(exdateListZone("20260708T100000", "Europe/Berlin"))
+ .isEqualTo(ZoneId.of("Europe/Berlin"))
+ assertThat(exdateListZone("W. Europe Standard Time;20260708T100000", "Europe/Berlin"))
+ .isEqualTo(ZoneId.of("Europe/Berlin"))
+ }
+
+ @Test
+ fun `a bare-zoned exclusion list splits on the instants its prefix names`() {
+ assertThat(
+ exdateAfter(
+ "Europe/Berlin;20260708T100000,20260729T100000",
+ instantAt("2026-07-15T08:00", "UTC"),
+ isAllDay = false,
+ timezone = null,
+ ),
+ ).isEqualTo("20260729T080000Z")
+ }
+
+ @Test
+ fun `an exdate drop keeps a list it cannot read whole, prefix and all`() {
+ // Stripping the prefix off a list and re-emitting the stamps it can't
+ // parse leaves them floating: "09:00 in Berlin" silently becomes 09:00 in
+ // whatever zone the next reader picks.
+ val values = buildOccurrenceExdateValues(
+ existingExdate = "Europe/Berlin;20260722T090000",
+ occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
+ dtStartMillis = 1_783_900_800_000L,
+ rrule = "FREQ=WEEKLY",
+ rdate = null,
+ duration = "P1D",
+ timezone = "Europe/Berlin",
+ allDay = 1,
+ )
+ assertThat(values[CalendarContract.Events.EXDATE])
+ .isEqualTo("Europe/Berlin;20260722T090000,20260715")
+ }
+
+ @Test
+ fun `a new exclusion joins an unreadable timed list in that list's own form`() {
+ val values = buildOccurrenceExdateValues(
+ existingExdate = "Europe/Berlin;sometime next Tuesday",
+ occurrenceMillis = 1_784_102_400_000L, // 2026-07-15T08:00:00Z == 10:00 Berlin
+ dtStartMillis = 1_783_929_600_000L,
+ rrule = "FREQ=DAILY",
+ rdate = null,
+ duration = "PT1H",
+ timezone = "Europe/Berlin",
+ allDay = 0,
+ )
+ assertThat(values[CalendarContract.Events.EXDATE])
+ .isEqualTo("Europe/Berlin;sometime next Tuesday,20260715T100000")
+ }
+
+ @Test
+ fun `an exdate drop rewrites RDATE along with the rule`() {
+ // A series may be a list of dates with no rule at all. Left out of the
+ // rewrite, the provider has only DTSTART to re-expand from and collapses
+ // the series to its first occurrence.
+ val values = buildOccurrenceExdateValues(
+ existingExdate = null,
+ occurrenceMillis = 1_784_102_400_000L,
+ dtStartMillis = 1_783_929_600_000L,
+ rrule = null,
+ rdate = "20260716T080000Z,20260722T080000Z",
+ duration = "PT1H",
+ timezone = "Europe/Berlin",
+ allDay = 0,
+ )
+ assertThat(values[CalendarContract.Events.RDATE])
+ .isEqualTo("20260716T080000Z,20260722T080000Z")
+ assertThat(values[CalendarContract.Events.RRULE]).isNull()
+ }
+
// --- EXDATE follows the series when its times change (Codeberg #248) ---
/** A weekly series whose displayed occurrence runs 15 July 2026, 09:00–10:00. */
@@ -627,6 +736,65 @@ class EventWriteMapperTest {
.isEqualTo("20260724")
}
+ @Test
+ fun `an all-day exclusion a sync adapter padded still moves with the series`() {
+ // The bug: only the bare yyyyMMdd form parsed, so a padded midnight stamp
+ // bailed the whole list and buildEventUpdateValues wrote no EXDATE at all
+ // — the deleted occurrence came back the moment the series was re-timed.
+ val series = instantAt("2026-07-01T00:00", "UTC")
+ val original = form(
+ isAllDay = true,
+ start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
+ end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
+ ).copy(rrule = "FREQ=WEEKLY")
+ val moved = original.copy(
+ start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
+ end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
+ )
+
+ val values = update(original, moved, series, "20260722T000000Z")
+ assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260724")
+ }
+
+ @Test
+ fun `an all-day exclusion padded without a Z is read the same way`() {
+ // DAVx5 and AOSP's own RecurrenceSet write a floating midnight for a
+ // date value. Only the trailing-Z spelling parsed, so the floating one
+ // bailed the whole list and the deleted occurrence came back.
+ val series = instantAt("2026-07-01T00:00", "UTC")
+ val original = form(
+ isAllDay = true,
+ start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
+ end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
+ ).copy(rrule = "FREQ=WEEKLY")
+ val moved = original.copy(
+ start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
+ end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
+ )
+
+ val values = update(original, moved, series, "20260722T000000")
+ assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260724")
+ }
+
+ @Test
+ fun `a padded all-day stamp naming any other time is still left alone`() {
+ // Not a form an all-day series is written in, so it could be naming
+ // something else entirely — guessing could exclude the wrong occurrence.
+ val series = instantAt("2026-07-01T00:00", "UTC")
+ val original = form(
+ isAllDay = true,
+ start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
+ end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
+ ).copy(rrule = "FREQ=WEEKLY")
+ val moved = original.copy(
+ start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
+ end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
+ )
+
+ val values = update(original, moved, series, "20260722T090000Z")
+ assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
+ }
+
@Test
fun `switching a series to all-day rewrites its exclusions as dates`() {
// The two forms aren't interchangeable: a date-time stamp on an all-day
@@ -673,9 +841,10 @@ class EventWriteMapperTest {
}
@Test
- fun `an exdate form we do not write is left untouched`() {
- // A sync adapter may store a TZID-parameterised or floating stamp. A stale
- // stamp excludes nothing; a mangled one could exclude the wrong occurrence.
+ fun `a zoned exdate list is read in the zone its prefix names`() {
+ // AOSP's RecurrenceSet form, TZID=;. Read as UTC the
+ // stamps name the wrong instants; skipped, every exclusion of the series is
+ // dropped the moment it is re-timed.
val original = julySeries()
val values = update(
original,
@@ -683,6 +852,35 @@ class EventWriteMapperTest {
instantAt("2026-07-01T09:00", "Europe/Berlin"),
"TZID=Europe/Berlin;20260722T090000",
)
+ // 22 July 09:00 Berlin (CEST, +2) == 07:00Z; the +1h edit puts it at 08:00Z.
+ assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722T080000Z")
+ }
+
+ @Test
+ fun `a zoned list whose zone this device cannot place falls back to the event's`() {
+ // Exchange spells zones its own way, so ZoneId.of refuses the name. The
+ // series' own zone is what such a list is written in anyway.
+ val original = julySeries()
+ val values = update(
+ original,
+ original.atHour(10),
+ instantAt("2026-07-01T09:00", "Europe/Berlin"),
+ "TZID=W. Europe Standard Time;20260722T090000",
+ )
+ assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722T080000Z")
+ }
+
+ @Test
+ fun `an exdate form we do not write is left untouched`() {
+ // A stale stamp excludes nothing; a mangled one could exclude the wrong
+ // occurrence, so anything unreadable is left exactly as it stands.
+ val original = julySeries()
+ val values = update(
+ original,
+ original.atHour(10),
+ instantAt("2026-07-01T09:00", "Europe/Berlin"),
+ "sometime next Tuesday",
+ )
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@@ -737,6 +935,7 @@ class EventWriteMapperTest {
existingExdate = "20260708T080000Z,20260715T080000Z,20260722T080000Z",
beginMillis = instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
+ timezone = null,
),
).isEqualTo("20260722T080000Z")
}
@@ -744,15 +943,25 @@ class EventWriteMapperTest {
@Test
fun `a split carries nothing when every exclusion is behind it`() {
assertThat(
- exdateAfter("20260708T080000Z", instantAt("2026-07-15T08:00", "UTC"), isAllDay = false),
+ exdateAfter(
+ "20260708T080000Z",
+ instantAt("2026-07-15T08:00", "UTC"),
+ isAllDay = false,
+ timezone = null,
+ ),
).isNull()
- assertThat(exdateAfter(null, 0L, isAllDay = false)).isNull()
+ assertThat(exdateAfter(null, 0L, isAllDay = false, timezone = null)).isNull()
}
@Test
fun `all-day exclusions split on their UTC date`() {
assertThat(
- exdateAfter("20260708,20260722", instantAt("2026-07-15T00:00", "UTC"), isAllDay = true),
+ exdateAfter(
+ "20260708,20260722",
+ instantAt("2026-07-15T00:00", "UTC"),
+ isAllDay = true,
+ timezone = null,
+ ),
).isEqualTo("20260722")
}
@@ -760,13 +969,28 @@ class EventWriteMapperTest {
fun `an unreadable exclusion carries nothing across a split`() {
assertThat(
exdateAfter(
- "20260722T080000Z,TZID=Europe/Berlin;20260729T100000",
+ "20260722T080000Z,sometime next Tuesday",
instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
+ timezone = null,
),
).isNull()
}
+ @Test
+ fun `a zoned exclusion list splits on the instants its prefix names`() {
+ // Canonicalised on the way out: the prefix that explained how to read the
+ // stamps doesn't survive the filter, so the survivors can't stay floating.
+ assertThat(
+ exdateAfter(
+ "TZID=Europe/Berlin;20260708T100000,20260729T100000",
+ instantAt("2026-07-15T08:00", "UTC"),
+ isAllDay = false,
+ timezone = null,
+ ),
+ ).isEqualTo("20260729T080000Z")
+ }
+
@Test
fun `split exclusions move by the same shift as the new series start`() {
// What the split path composes: filter to the tail, then re-time it by the
@@ -777,6 +1001,7 @@ class EventWriteMapperTest {
"20260716T070000Z",
instantAt("2026-07-15T07:00", "UTC"),
isAllDay = false,
+ timezone = null,
),
original = original,
updated = original.atHour(11),
@@ -980,4 +1205,173 @@ class EventWriteMapperTest {
val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null))
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S")
}
+
+ @Test
+ fun `a detached all-day occurrence matches its date-only exdate stamp`() {
+ val parent = buildOccurrenceExdateValues(
+ existingExdate = null,
+ occurrenceMillis = 1_781_136_000_000L, // 2026-06-11T00:00:00Z
+ dtStartMillis = 1_749_600_000_000L,
+ rrule = "FREQ=YEARLY",
+ rdate = null,
+ duration = "P1D",
+ timezone = "UTC",
+ allDay = 1,
+ )
+ val detached = form(
+ isAllDay = true,
+ start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
+ end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
+ ).copy(rrule = "FREQ=YEARLY").toDetachedOccurrence()
+ val inserted = buildEventInsertValues(
+ form = detached,
+ uid = "uid@calendula",
+ times = detached.toWriteTimes(berlin),
+ )
+ assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611")
+ assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L)
+ }
+
+ @Test
+ fun `a detached all-day occurrence stays on UTC midnights`() {
+ val edited = form(
+ isAllDay = true,
+ start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
+ end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
+ ).copy(title = "Birthday", rrule = "FREQ=YEARLY")
+ val detached = edited.toDetachedOccurrence()
+
+ val values = buildEventInsertValues(
+ form = detached,
+ uid = "uid@calendula",
+ times = detached.toWriteTimes(berlin),
+ )
+ assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
+ assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
+ assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L)
+ // Exclusive DTEND — the next UTC midnight.
+ assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_222_400_000L)
+ assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
+ }
+
+ @Test
+ fun `a detached occurrence carries every edited field onto the new row`() {
+ // Built from the form, not cloned from the parent: a field dropped here
+ // is an edit silently lost.
+ val edited = form(timezone = "America/New_York").copy(
+ title = " Standup ",
+ location = "Room 2",
+ description = "notes",
+ reminders = listOf(10),
+ availability = Availability.Free,
+ accessLevel = AccessLevel.Private,
+ rrule = "FREQ=DAILY",
+ )
+ val detached = edited.toDetachedOccurrence()
+
+ val values = buildEventInsertValues(
+ form = detached,
+ uid = "uid@calendula",
+ times = detached.toWriteTimes(berlin),
+ )
+ assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Standup")
+ assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Room 2")
+ assertThat(values[CalendarContract.Events.DESCRIPTION]).isEqualTo("notes")
+ assertThat(values[CalendarContract.Events.AVAILABILITY])
+ .isEqualTo(CalendarContract.Events.AVAILABILITY_FREE)
+ assertThat(values[CalendarContract.Events.ACCESS_LEVEL])
+ .isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
+ // The pinned zone survives — never re-anchored to the device.
+ assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
+ assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("uid@calendula")
+ // Reminders aren't columns; the insert path seeds them from the form.
+ assertThat(detached.reminders).containsExactly(10)
+ }
+
+ @Test
+ fun `a detached occurrence drops the series rule and becomes a one-off row`() {
+ val edited = form().copy(title = "Moved", rrule = "FREQ=WEEKLY;BYDAY=TH")
+ val detached = edited.toDetachedOccurrence()
+
+ val values = buildEventInsertValues(
+ form = detached,
+ uid = "uid@calendula",
+ times = detached.toWriteTimes(berlin),
+ )
+ assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
+ // A surviving rule would insert a second *series* overlapping the first
+ // (Codeberg #234's stray duplicate).
+ assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
+ assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
+ // A one-off row carries DTEND rather than a duration.
+ assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
+ assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_170_200_000L)
+ assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
+ }
+
+ @Test
+ fun `an exclusion is found anywhere in a multi-entry exdate list`() {
+ // Whitespace after a comma is legal in the stored column.
+ assertThat(
+ exdateContains(
+ "20260604T080000Z, 20260611T080000Z,20260618T080000Z",
+ 1_781_164_800_000L,
+ isAllDay = false,
+ timezone = null,
+ ),
+ ).isTrue()
+ }
+
+ @Test
+ fun `an occurrence already excluded is recognised, timed and all-day`() {
+ // Detaching twice would leave a second standalone copy.
+ assertThat(
+ exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false, timezone = null),
+ ).isTrue()
+ assertThat(
+ exdateContains("20260611", 1_781_136_000_000L, isAllDay = true, timezone = null),
+ ).isTrue()
+ }
+
+ @Test
+ fun `an occurrence not in the exdate list is not mistaken for an excluded one`() {
+ assertThat(exdateContains(null, 1_781_164_800_000L, isAllDay = false, timezone = null))
+ .isFalse()
+ assertThat(exdateContains("", 1_781_164_800_000L, isAllDay = false, timezone = null))
+ .isFalse()
+ // A neighbouring occurrence must not match — the guard is per-instant.
+ assertThat(
+ exdateContains("20260610T080000Z", 1_781_164_800_000L, isAllDay = false, timezone = null),
+ ).isFalse()
+ }
+
+ @Test
+ fun `the detached row lands exactly where the parent's exdate removes it`() {
+ // The two halves must agree on the instant, or the user sees the
+ // occurrence twice or not at all.
+ val edited = form().copy(title = "Renamed", rrule = "FREQ=WEEKLY")
+ val occurrenceMillis = 1_781_164_800_000L
+
+ val parent = buildOccurrenceExdateValues(
+ existingExdate = null,
+ occurrenceMillis = occurrenceMillis,
+ dtStartMillis = 1_780_560_000_000L,
+ rrule = "FREQ=WEEKLY",
+ rdate = null,
+ duration = "P5400S",
+ timezone = "Europe/Berlin",
+ allDay = 0,
+ )
+ val detached = edited.toDetachedOccurrence()
+ val inserted = buildEventInsertValues(
+ form = detached,
+ uid = "uid@calendula",
+ times = detached.toWriteTimes(berlin),
+ )
+ assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611T080000Z")
+ assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(occurrenceMillis)
+ // The parent keeps its own anchor and rule — only this occurrence leaves.
+ assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L)
+ assertThat(parent[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
+ }
}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt
index a66fd40..e4adc62 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt
@@ -23,6 +23,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var searchResult: (String) -> List = { _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List = { emptyList() }
+ var publishedEventColorsResult: (Long) -> List = { emptyList() }
var exportableEventsResult: List = emptyList()
/** The [calendarIds] the last [exportableEvents] call received (null = all). */
var lastExportableEventsCalendarIds: Set? = null
@@ -81,7 +82,12 @@ internal class FakeCalendarDataSource : CalendarDataSource {
eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List =
eventColorPaletteResult(calendarId)
- override fun exportableEvents(calendarIds: Set?): List {
+ override fun publishedEventColors(calendarId: Long): List =
+ publishedEventColorsResult(calendarId)
+ override fun exportableEvents(
+ calendarIds: Set?,
+ allDayReminderTimeMinutes: Int,
+ ): List {
lastExportableEventsCalendarIds = calendarIds
return exportableEventsResult
}
@@ -91,8 +97,25 @@ internal class FakeCalendarDataSource : CalendarDataSource {
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
val importedEvents = mutableListOf>()
- override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
- writeError?.let { throw it }
+ /** Thrown instead of [writeError] for events whose summary is in this set. */
+ val failingImportSummaries = mutableSetOf()
+
+ /** Imports let through before [writeError] applies, for part-way failures. */
+ var importsBeforeError: Int = 0
+ private var imports = 0
+
+ /** The [colorPalette] the last [insertImportedEvent] call received. */
+ var lastImportPalette: List = emptyList()
+
+ override fun insertImportedEvent(
+ event: ParsedIcsEvent,
+ calendarId: Long,
+ allDayReminderTimeMinutes: Int,
+ colorPalette: List,
+ ): Long {
+ lastImportPalette = colorPalette
+ if (imports++ >= importsBeforeError) writeError?.let { throw it }
+ if (event.summary in failingImportSummaries) error("rejected: ${event.summary}")
importedEvents += event to calendarId
return nextInsertId
}
@@ -173,6 +196,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun updateOccurrence(
eventId: Long,
beginMillis: Long,
+ original: EventForm,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt
index 552e1d9..6045ef4 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt
@@ -4,6 +4,7 @@ import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventStatus
import org.junit.jupiter.api.Test
+import java.time.ZoneId
class IcsExportMapperTest {
@@ -20,7 +21,11 @@ class IcsExportMapperTest {
EventExportProjection.IDX_AVAILABILITY to CalendarContract.Events.AVAILABILITY_BUSY,
)
- val event = reader.toIcsEvent(reminderMinutes = listOf(10), calendarName = "Personal")
+ val event = reader.toIcsEvent(
+ reminderMinutes = listOf(10),
+ calendarName = "Personal",
+ allDayReminderTimeMinutes = NINE_AM,
+ )
assertThat(event.uid).isEqualTo("abc@host")
assertThat(event.summary).isEqualTo("Standup")
@@ -47,7 +52,11 @@ class IcsExportMapperTest {
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
- val event = reader.toIcsEvent(reminderMinutes = emptyList(), calendarName = null)
+ val event = reader.toIcsEvent(
+ reminderMinutes = emptyList(),
+ calendarName = null,
+ allDayReminderTimeMinutes = NINE_AM,
+ )
assertThat(event.uid).isEqualTo("7-1000000@calendula")
assertThat(event.recurrenceRule).isEqualTo("FREQ=WEEKLY")
@@ -65,6 +74,224 @@ class IcsExportMapperTest {
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
- assertThat(reader.toIcsEvent(emptyList(), null).isAllDay).isTrue()
+ assertThat(reader.toIcsEvent(emptyList(), null, NINE_AM).isAllDay).isTrue()
+ }
+
+ @Test
+ fun `an all-day reminder is exported as its whole-day lead time`() {
+ // The raw offset encodes the firing time and is normally negative; left
+ // raw it would be written as a trigger after the event and dropped, so
+ // the backup would come back with no reminder at all.
+ val reader = MapColumnReader(
+ EventExportProjection.IDX_ID to 1L,
+ EventExportProjection.IDX_TITLE to "Anna Birthday",
+ EventExportProjection.IDX_DTSTART to 1_782_864_000_000L, // 2026-07-01 UTC, CEST
+ EventExportProjection.IDX_DTEND to 1_782_950_400_000L,
+ EventExportProjection.IDX_ALL_DAY to 1,
+ EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
+ )
+
+ val onTheDay = reader.toIcsEvent(listOf(-420), null, NINE_AM, BERLIN)
+ val dayBefore = reader.toIcsEvent(listOf(1_020), null, NINE_AM, BERLIN)
+
+ assertThat(onTheDay.reminderMinutes).containsExactly(0)
+ assertThat(dayBefore.reminderMinutes).containsExactly(1_440)
+ }
+
+ private companion object {
+ const val NINE_AM = 9 * 60
+ val BERLIN: ZoneId = ZoneId.of("Europe/Berlin")
+ }
+
+ @Test
+ fun `a recurring row carries its EXDATE exclusions`() {
+ val reader = MapColumnReader(
+ EventExportProjection.IDX_ID to 7L,
+ EventExportProjection.IDX_TITLE to "Weekly",
+ EventExportProjection.IDX_DTSTART to 1_000_000L,
+ EventExportProjection.IDX_DURATION to "P3600S",
+ EventExportProjection.IDX_ALL_DAY to 0,
+ EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
+ EventExportProjection.IDX_EXDATE to "20260901T080000Z,20260908T080000Z",
+ EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
+ )
+
+ val event = reader.toIcsEvent(
+ reminderMinutes = emptyList(),
+ calendarName = null,
+ allDayReminderTimeMinutes = NINE_AM,
+ )
+
+ assertThat(event.exDates)
+ .containsExactly("20260901T080000Z", "20260908T080000Z").inOrder()
+ }
+
+ @Test
+ fun `an all-day EXDATE keeps only the day, however the adapter padded it`() {
+ val reader = MapColumnReader(
+ EventExportProjection.IDX_ID to 8L,
+ EventExportProjection.IDX_TITLE to "Holiday",
+ EventExportProjection.IDX_DTSTART to 0L,
+ EventExportProjection.IDX_DURATION to "P1D",
+ EventExportProjection.IDX_ALL_DAY to 1,
+ EventExportProjection.IDX_RRULE to "FREQ=YEARLY",
+ EventExportProjection.IDX_EXDATE to "20260901,20270901T000000Z",
+ EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
+ )
+
+ val event = reader.toIcsEvent(
+ reminderMinutes = emptyList(),
+ calendarName = null,
+ allDayReminderTimeMinutes = NINE_AM,
+ )
+
+ assertThat(event.exDates).containsExactly("20260901", "20270901").inOrder()
+ }
+
+ @Test
+ fun `a TZID-prefixed EXDATE is resolved to UTC, not handed on verbatim`() {
+ // The provider stores a zoned exclusion as AOSP RecurrenceSet does. Passed
+ // through, the writer would emit `EXDATE:TZID=Europe/Berlin;2026...` — the
+ // parameter belongs before the colon, so the line is unreadable.
+ val reader = MapColumnReader(
+ EventExportProjection.IDX_ID to 11L,
+ EventExportProjection.IDX_TITLE to "Weekly",
+ EventExportProjection.IDX_DTSTART to 1_000_000L,
+ EventExportProjection.IDX_DURATION to "P3600S",
+ EventExportProjection.IDX_ALL_DAY to 0,
+ EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
+ EventExportProjection.IDX_EXDATE to
+ "TZID=Europe/Berlin;20260722T090000,20260729T090000",
+ EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
+ )
+
+ val event = reader.toIcsEvent(
+ reminderMinutes = emptyList(),
+ calendarName = null,
+ allDayReminderTimeMinutes = NINE_AM,
+ )
+
+ // 09:00 Berlin in July is CEST (+2) == 07:00Z.
+ assertThat(event.exDates)
+ .containsExactly("20260722T070000Z", "20260729T070000Z").inOrder()
+ }
+
+ @Test
+ fun `a zoned EXDATE in the provider's own bare form resolves in that zone`() {
+ // AOSP's RecurrenceSet stores the TZID parameter's value alone. Read as
+ // if it were unzoned, 09:00 Berlin exported as 09:00Z — two hours out,
+ // so the exclusion no longer matched its occurrence in the file.
+ val reader = MapColumnReader(
+ EventExportProjection.IDX_ID to 14L,
+ EventExportProjection.IDX_TITLE to "Weekly",
+ EventExportProjection.IDX_DTSTART to 1_000_000L,
+ EventExportProjection.IDX_DURATION to "P3600S",
+ EventExportProjection.IDX_ALL_DAY to 0,
+ EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
+ EventExportProjection.IDX_EXDATE to "Europe/Berlin;20260722T090000",
+ EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
+ )
+
+ val event = reader.toIcsEvent(
+ reminderMinutes = emptyList(),
+ calendarName = null,
+ allDayReminderTimeMinutes = NINE_AM,
+ )
+
+ assertThat(event.exDates).containsExactly("20260722T070000Z")
+ }
+
+ @Test
+ fun `a TZID this device cannot place falls back to the row's own zone`() {
+ // Exchange spells zones its own way, so ZoneId.of refuses the name.
+ // Stripping the prefix and emitting the stamp as it stands would export a
+ // floating time against a `DTSTART;TZID=…` line — every reader in another
+ // zone resolves it to another instant and the exclusion stops matching.
+ val reader = MapColumnReader(
+ EventExportProjection.IDX_ID to 13L,
+ EventExportProjection.IDX_TITLE to "Weekly",
+ EventExportProjection.IDX_DTSTART to 1_000_000L,
+ EventExportProjection.IDX_DURATION to "P3600S",
+ EventExportProjection.IDX_ALL_DAY to 0,
+ EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
+ EventExportProjection.IDX_EXDATE to
+ "TZID=W. Europe Standard Time;20260722T090000",
+ EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
+ )
+
+ val event = reader.toIcsEvent(
+ reminderMinutes = emptyList(),
+ calendarName = null,
+ allDayReminderTimeMinutes = NINE_AM,
+ )
+
+ assertThat(event.exDates).containsExactly("20260722T070000Z")
+ }
+
+ @Test
+ fun `an all-day EXDATE loses a TZID prefix along with its time`() {
+ val reader = MapColumnReader(
+ EventExportProjection.IDX_ID to 12L,
+ EventExportProjection.IDX_TITLE to "Holiday",
+ EventExportProjection.IDX_DTSTART to 0L,
+ EventExportProjection.IDX_DURATION to "P1D",
+ EventExportProjection.IDX_ALL_DAY to 1,
+ EventExportProjection.IDX_RRULE to "FREQ=YEARLY",
+ EventExportProjection.IDX_EXDATE to "TZID=Europe/Berlin;20260901T000000",
+ EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
+ )
+
+ val event = reader.toIcsEvent(
+ reminderMinutes = emptyList(),
+ calendarName = null,
+ allDayReminderTimeMinutes = NINE_AM,
+ )
+
+ assertThat(event.exDates).containsExactly("20260901")
+ }
+
+ @Test
+ fun `a default-lead all-day reminder is not decoded into an at-start alarm`() {
+ // MINUTES_DEFAULT (-1) says "use the account default", not "0 minutes
+ // before". Decoded, it landed on the start date and became a real alarm
+ // that IcsWriter no longer dropped.
+ val reader = MapColumnReader(
+ EventExportProjection.IDX_ID to 13L,
+ EventExportProjection.IDX_TITLE to "Birthday",
+ EventExportProjection.IDX_DTSTART to 0L,
+ EventExportProjection.IDX_DURATION to "P1D",
+ EventExportProjection.IDX_ALL_DAY to 1,
+ EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
+ )
+
+ val event = reader.toIcsEvent(
+ reminderMinutes = listOf(-1),
+ calendarName = null,
+ allDayReminderTimeMinutes = NINE_AM,
+ zone = BERLIN,
+ )
+
+ assertThat(event.reminderMinutes).containsExactly(-1)
+ }
+
+ @Test
+ fun `a one-off row exports no exclusions even if the column is set`() {
+ val reader = MapColumnReader(
+ EventExportProjection.IDX_ID to 9L,
+ EventExportProjection.IDX_TITLE to "Standup",
+ EventExportProjection.IDX_DTSTART to 1_000_000L,
+ EventExportProjection.IDX_DTEND to 1_900_000L,
+ EventExportProjection.IDX_ALL_DAY to 0,
+ EventExportProjection.IDX_EXDATE to "20260901T080000Z",
+ EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
+ )
+
+ val event = reader.toIcsEvent(
+ reminderMinutes = emptyList(),
+ calendarName = null,
+ allDayReminderTimeMinutes = NINE_AM,
+ )
+
+ assertThat(event.exDates).isEmpty()
}
}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt
new file mode 100644
index 0000000..3acd9f4
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt
@@ -0,0 +1,119 @@
+package de.jeanlucmakiola.calendula.data.calendar
+
+import android.provider.CalendarContract
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.calendula.domain.EventColorOption
+import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
+import org.junit.jupiter.api.Test
+import kotlin.time.Instant
+
+class ImportedEventValuesTest {
+
+ private val dayStart = Instant.parse("2026-08-19T00:00:00Z")
+
+ private fun allDay(
+ rrule: String? = null,
+ exDates: List = emptyList(),
+ color: Int? = null,
+ days: Int = 1,
+ ) = ParsedIcsEvent(
+ uid = "u@x",
+ summary = "Anna Birthday",
+ start = dayStart,
+ end = Instant.fromEpochMilliseconds(dayStart.toEpochMilliseconds() + days * 86_400_000L),
+ isAllDay = true,
+ zoneId = "UTC",
+ recurrenceRule = rrule,
+ exDates = exDates,
+ color = color,
+ )
+
+ private fun values(event: ParsedIcsEvent, palette: List = emptyList()) =
+ buildImportedEventValues(event, calendarId = 7L, uid = "u@x", palette = palette)
+
+ @Test
+ fun `a one-off carries DTEND and no recurrence`() {
+ val v = values(allDay())
+ assertThat(v[CalendarContract.Events.DTEND])
+ .isEqualTo(dayStart.toEpochMilliseconds() + 86_400_000L)
+ assertThat(v).doesNotContainKey(CalendarContract.Events.DURATION)
+ assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
+ assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
+ }
+
+ @Test
+ fun `a recurring row carries DURATION instead of DTEND`() {
+ val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1"))
+ assertThat(v).doesNotContainKey(CalendarContract.Events.DTEND)
+ assertThat(v[CalendarContract.Events.RRULE]).isEqualTo("FREQ=YEARLY;INTERVAL=1")
+ assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
+ }
+
+ @Test
+ fun `an all-day series is never zero days long`() {
+ // A degenerate span would expand into no instances at all — the series
+ // would simply not exist (Codeberg #225).
+ val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1", days = 0))
+ assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
+ }
+
+ @Test
+ fun `EXDATEs ride along with the recurrence`() {
+ val v = values(allDay(rrule = "FREQ=DAILY", exDates = listOf("20260820", "20260822")))
+ assertThat(v[CalendarContract.Events.EXDATE]).isEqualTo("20260820,20260822")
+ }
+
+ @Test
+ fun `EXDATEs are not written without a recurrence to exclude from`() {
+ val v = values(allDay(exDates = listOf("20260820")))
+ assertThat(v).doesNotContainKey(CalendarContract.Events.EXDATE)
+ }
+
+ @Test
+ fun `an imported colour is written raw when the account publishes no palette`() {
+ val v = values(allDay(color = -2818048))
+ assertThat(v[CalendarContract.Events.EVENT_COLOR]).isEqualTo(-2818048)
+ assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isNull()
+ }
+
+ @Test
+ fun `an imported colour snaps to the nearest published palette key`() {
+ val palette = listOf(
+ EventColorOption(key = "1", argb = 0xFF0000FF.toInt()), // blue
+ EventColorOption(key = "2", argb = 0xFFFF4500.toInt()), // orangered
+ EventColorOption(key = "3", argb = 0xFF008000.toInt()), // green
+ )
+ // Tomato — visually an orangered, nothing like the blue or the green.
+ val v = values(allDay(color = 0xFFFF6347.toInt()), palette)
+
+ assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isEqualTo("2")
+ // A raw colour alongside a key is what a palette calendar rejects.
+ assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
+ }
+
+ @Test
+ fun `an event with no colour touches neither colour column`() {
+ val v = values(allDay())
+ assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
+ assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
+ }
+
+ @Test
+ fun `a timed event keeps its own zone and a seconds duration`() {
+ val start = Instant.parse("2026-08-19T08:00:00Z")
+ val event = ParsedIcsEvent(
+ uid = "u@x",
+ summary = "Standup",
+ start = start,
+ end = Instant.parse("2026-08-19T08:15:00Z"),
+ isAllDay = false,
+ zoneId = "Europe/Berlin",
+ recurrenceRule = "FREQ=WEEKLY",
+ )
+
+ val v = values(event)
+ assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
+ assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P900S")
+ assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ProviderReminderMinutesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ProviderReminderMinutesTest.kt
new file mode 100644
index 0000000..c001e0a
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ProviderReminderMinutesTest.kt
@@ -0,0 +1,33 @@
+package de.jeanlucmakiola.calendula.data.calendar
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+
+/**
+ * The provider's "use the account default" sentinel is translated out here, so
+ * the pure planner only ever sees offsets it can resolve (#75).
+ */
+class ProviderReminderMinutesTest {
+
+ @Test
+ fun `the account-default sentinel is dropped`() {
+ // Taken as an offset it armed an alarm a minute *after* the event began,
+ // while the detail screen said "Default reminder" for the same row.
+ assertThat(mapOf(1L to listOf(-1)).withoutProviderDefaults())
+ .containsExactly(1L, emptyList())
+ }
+
+ @Test
+ fun `a real reminder beside a default one survives`() {
+ assertThat(mapOf(1L to listOf(-1, 10)).withoutProviderDefaults())
+ .containsExactly(1L, listOf(10))
+ }
+
+ @Test
+ fun `an all-day encoding that is negative is not mistaken for the sentinel`() {
+ // An all-day offset carries the firing time of day, so it is routinely
+ // negative; a `< 0` test would silence reminders that do fire.
+ assertThat(mapOf(1L to listOf(-420, -2_000)).withoutProviderDefaults())
+ .containsExactly(1L, listOf(-420, -2_000))
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsColorTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsColorTest.kt
new file mode 100644
index 0000000..0df9328
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsColorTest.kt
@@ -0,0 +1,40 @@
+package de.jeanlucmakiola.calendula.domain.ics
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+
+class IcsColorTest {
+
+ @Test
+ fun `a raw Android colour int round-trips`() {
+ assertThat(parseIcsColorValue("-2818048")).isEqualTo(-2818048)
+ }
+
+ @Test
+ fun `a CSS3 name resolves opaque`() {
+ assertThat(parseIcsColorValue("tomato")).isEqualTo(0xFFFF6347.toInt())
+ assertThat(parseIcsColorValue("REBECCAPURPLE")).isEqualTo(0xFF663399.toInt())
+ }
+
+ @Test
+ fun `hex forms are accepted with and without alpha`() {
+ assertThat(parseIcsColorValue("#ff6347")).isEqualTo(0xFFFF6347.toInt())
+ assertThat(parseIcsColorValue("#80ff6347")).isEqualTo(0x80FF6347.toInt())
+ }
+
+ @Test
+ fun `a colour with no alpha byte is made opaque`() {
+ // 0x00FF6347 as a decimal int — an RGB triple that lost its alpha.
+ assertThat(parseIcsColorValue("16737095")).isEqualTo(0xFFFF6347.toInt())
+ }
+
+ @Test
+ fun `unusable values resolve to null`() {
+ assertThat(parseIcsColorValue(null)).isNull()
+ assertThat(parseIcsColorValue("")).isNull()
+ // Fossify interpolates a nullable calendar straight into the property.
+ assertThat(parseIcsColorValue("null")).isNull()
+ assertThat(parseIcsColorValue("chartreusey")).isNull()
+ assertThat(parseIcsColorValue("#abc")).isNull()
+ }
+}
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
new file mode 100644
index 0000000..d663db6
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt
@@ -0,0 +1,607 @@
+package de.jeanlucmakiola.calendula.domain.ics
+
+import com.google.common.truth.Truth.assertThat
+import kotlinx.datetime.TimeZone
+import org.junit.jupiter.api.Test
+import kotlin.time.Instant
+
+/**
+ * Reading the Simple Calendar / Fossify export dialect (Codeberg #225).
+ *
+ * Every fixture is shaped as `IcsExporter.writeEvent` emits it — property order,
+ * the `X-FOSSIFY-*` extensions and the `P0DT1H5M0S` duration spelling included.
+ *
+ * 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. 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 {
+
+ private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
+
+ private fun fossify(vararg body: String) = (
+ listOf(
+ "BEGIN:VCALENDAR",
+ "PRODID:-//Fossify//NONSGML Event Calendar//EN",
+ "VERSION:2.0",
+ ) + body + listOf("END:VCALENDAR")
+ ).joinToString("\r\n")
+
+ private fun days(event: ParsedIcsEvent): Long =
+ (event.end - event.start).inWholeMilliseconds / 86_400_000L
+
+ @Test
+ fun `an all-day event keeps the exact span the file gives it`() {
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Anna Birthday",
+ "UID:abc123",
+ "X-FOSSIFY-CATEGORY-COLOR:-1155931",
+ "DTSTART;VALUE=DATE:19900412",
+ "DTEND;VALUE=DATE:19900413",
+ "X-FOSSIFY-MISSING-YEAR:0",
+ "RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
+ "END:VEVENT",
+ ),
+ )
+
+ val event = result.events.single()
+ assertThat(event.isAllDay).isTrue()
+ 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
+ // PRODID names Fossify, so any producer-sniffing shortcut would corrupt
+ // it — the file is plain, conformant iCalendar.
+ val text = checkNotNull(
+ javaClass.classLoader?.getResourceAsStream("ics/fossify-holidays-at.ics"),
+ ).use { it.readBytes().toString(Charsets.UTF_8) }
+
+ val result = parser.parse(text)
+
+ assertThat(result.events.map { it.summary })
+ .containsExactly("Neujahr", "Heilige Drei Könige").inOrder()
+ assertThat(result.events.map { days(it) }).containsExactly(1L, 1L)
+ assertThat(result.events.map { it.recurrenceRule }).containsExactly("FREQ=YEARLY", "FREQ=YEARLY")
+ }
+
+ @Test
+ fun `an all-day event with no DTEND lasts one day`() {
+ // RFC 5545 3.6.1, and the span the provider needs to expand a series.
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Holiday",
+ "DTSTART;VALUE=DATE:20260819",
+ "END:VEVENT",
+ ),
+ )
+
+ assertThat(days(result.events.single())).isEqualTo(1)
+ }
+
+ @Test
+ fun `a zero-length all-day event is widened rather than left to vanish`() {
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Holiday",
+ "DTSTART;VALUE=DATE:20260819",
+ "DTEND;VALUE=DATE:20260819",
+ "END:VEVENT",
+ ),
+ )
+
+ assertThat(days(result.events.single())).isEqualTo(1)
+ }
+
+ @Test
+ fun `a malformed repeat rule is repaired rather than passed to the provider`() {
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Standup",
+ "DTSTART:20260819T080000Z",
+ "DTEND:20260819T081500Z",
+ "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=",
+ "END:VEVENT",
+ ),
+ )
+
+ assertThat(result.events.single().recurrenceRule).isEqualTo("FREQ=WEEKLY;INTERVAL=1")
+ assertThat(result.warnings).contains(IcsParseWarning.RecurrenceRuleRepaired)
+ }
+
+ @Test
+ fun `a task is imported as an event and reported`() {
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VTODO",
+ "SUMMARY:Pay rent",
+ "DTSTART;VALUE=DATE:20260901",
+ "END:VTODO",
+ ),
+ )
+
+ val task = result.events.single()
+ assertThat(task.isTask).isTrue()
+ assertThat(task.summary).isEqualTo("Pay rent")
+ assertThat(days(task)).isEqualTo(1)
+ assertThat(result.warnings).contains(IcsParseWarning.TasksImportedAsEvents)
+ }
+
+ @Test
+ fun `a task dated only by DUE still imports`() {
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VTODO",
+ "SUMMARY:File taxes",
+ "DUE;VALUE=DATE:20260901",
+ "END:VTODO",
+ ),
+ )
+
+ assertThat(result.events.single().summary).isEqualTo("File taxes")
+ }
+
+ @Test
+ fun `a task with both DTSTART and DUE spans between them`() {
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VTODO",
+ "SUMMARY:Write report",
+ "DTSTART:20260901T090000Z",
+ "DUE:20260901T170000Z",
+ "END:VTODO",
+ ),
+ )
+
+ val task = result.events.single()
+ assertThat(task.start).isEqualTo(Instant.parse("2026-09-01T09:00:00Z"))
+ assertThat(task.end).isEqualTo(Instant.parse("2026-09-01T17:00:00Z"))
+ }
+
+ @Test
+ fun `a DUE ahead of the DTSTART is still the end`() {
+ // Property order isn't guaranteed; DUE must not be taken for the start.
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VTODO",
+ "SUMMARY:Write report",
+ "DUE:20260901T170000Z",
+ "DTSTART:20260901T090000Z",
+ "END:VTODO",
+ ),
+ )
+
+ val task = result.events.single()
+ assertThat(task.start).isEqualTo(Instant.parse("2026-09-01T09:00:00Z"))
+ assertThat(task.end).isEqualTo(Instant.parse("2026-09-01T17:00:00Z"))
+ }
+
+ @Test
+ fun `the per-event colour wins over the calendar colour`() {
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Holiday",
+ "X-FOSSIFY-CATEGORY-COLOR:-1155931",
+ "COLOR:tomato",
+ "X-FOSSIFY-EVENT-COLOR:-2818048",
+ "DTSTART:20260819T080000Z",
+ "DTEND:20260819T090000Z",
+ "END:VEVENT",
+ ),
+ )
+
+ assertThat(result.events.single().color).isEqualTo(-2818048)
+ }
+
+ @Test
+ fun `the calendar colour stands in when the event has none of its own`() {
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Holiday",
+ "X-FOSSIFY-CATEGORY-COLOR:-1155931",
+ "DTSTART:20260819T080000Z",
+ "DTEND:20260819T090000Z",
+ "END:VEVENT",
+ ),
+ )
+
+ assertThat(result.events.single().color).isEqualTo(-1155931)
+ }
+
+ @Test
+ fun `the legacy Simple Calendar colour spellings are read too`() {
+ val result = parser.parse(
+ listOf(
+ "BEGIN:VCALENDAR",
+ "PRODID:-//Simple Mobile Tools//NONSGML Event Calendar//EN",
+ "BEGIN:VEVENT",
+ "SUMMARY:Holiday",
+ "X-SMT-CATEGORY-COLOR:-1155931",
+ "DTSTART:20260819T080000Z",
+ "DTEND:20260819T090000Z",
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ).joinToString("\r\n"),
+ )
+
+ assertThat(result.events.single().color).isEqualTo(-1155931)
+ }
+
+ @Test
+ fun `the literal null Fossify writes for a missing calendar is not a colour`() {
+ // Both properties interpolate a nullable calendar straight into the value.
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Holiday",
+ "X-FOSSIFY-CATEGORY-COLOR:null",
+ "CATEGORIES:null",
+ "DTSTART:20260819T080000Z",
+ "DTEND:20260819T090000Z",
+ "END:VEVENT",
+ ),
+ )
+
+ val event = result.events.single()
+ assertThat(event.color).isNull()
+ assertThat(event.calendarName).isNull()
+ }
+
+ @Test
+ fun `CATEGORIES names the source calendar`() {
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Anna Birthday",
+ "CATEGORIES:Birthdays",
+ "DTSTART;VALUE=DATE:20260819",
+ "DTEND;VALUE=DATE:20260820",
+ "END:VEVENT",
+ ),
+ )
+
+ assertThat(result.events.single().calendarName).isEqualTo("Birthdays")
+ }
+
+ @Test
+ fun `CATEGORIES does not outrank a property that names the calendar outright`() {
+ // Elsewhere CATEGORIES is a tag list, so it only stands in where nothing
+ // else named the calendar — X-WR-CALNAME and our own label win.
+ val result = IcsParser().parse(
+ listOf(
+ "BEGIN:VCALENDAR",
+ "X-WR-CALNAME:Work",
+ "BEGIN:VEVENT",
+ "SUMMARY:Standup",
+ "CATEGORIES:Holiday",
+ "DTSTART:20260819T080000Z",
+ "DTEND:20260819T090000Z",
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ).joinToString("\r\n"),
+ )
+
+ assertThat(result.events.single().calendarName).isEqualTo("Work")
+ }
+
+ @Test
+ fun `X-WR-CALNAME names the calendar wherever in the file it sits`() {
+ // Why the property is scanned up front instead of in document order: an
+ // exporter that writes it at the tail still names the calendar every
+ // event in the file came from.
+ val result = IcsParser().parse(
+ listOf(
+ "BEGIN:VCALENDAR",
+ "BEGIN:VEVENT",
+ "SUMMARY:Standup",
+ "DTSTART:20260819T080000Z",
+ "DTEND:20260819T090000Z",
+ "END:VEVENT",
+ "X-WR-CALNAME:Work",
+ "END:VCALENDAR",
+ ).joinToString("\r\n"),
+ )
+
+ assertThat(result.events.single().calendarName).isEqualTo("Work")
+ }
+
+ @Test
+ fun `a multi-valued CATEGORIES is a tag list, not a calendar name`() {
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Standup",
+ "CATEGORIES:Meeting,Personal",
+ "DTSTART:20260819T080000Z",
+ "DTEND:20260819T090000Z",
+ "END:VEVENT",
+ ),
+ )
+
+ assertThat(result.events.single().calendarName).isNull()
+ }
+
+ @Test
+ fun `a reminder written as a positive trigger is not read as a lead time`() {
+ // The exporter flips the sign for reminders stored below -1
+ // (`sign = if (reminder.minutes < -1) "" else "-"`), so a trigger can
+ // point after the start. On an all-day event that means a time of day.
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Anna Birthday",
+ "DTSTART;VALUE=DATE:20260819",
+ "DTEND;VALUE=DATE:20260820",
+ "BEGIN:VALARM",
+ "ACTION:DISPLAY",
+ "TRIGGER:P0DT9H0M0S",
+ "END:VALARM",
+ "END:VEVENT",
+ ),
+ )
+
+ val event = result.events.single()
+ assertThat(event.reminderMinutes).containsExactly(-540)
+ assertThat(event.semanticReminderMinutes()).containsExactly(0)
+ }
+
+ @Test
+ fun `an all-day reminder whose firing time is after noon keeps its full day`() {
+ // The raw offset is `days * 1440 - timeOfDay`, so an 18:00 notification
+ // time puts "1 day before" only six hours ahead of the UTC midnight.
+ // Rounding to the nearest day would read that as "on the day".
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Anna Birthday",
+ "DTSTART;VALUE=DATE:20260819",
+ "DTEND;VALUE=DATE:20260820",
+ "BEGIN:VALARM",
+ "ACTION:DISPLAY",
+ "TRIGGER:-P0DT6H0M0S",
+ "END:VALARM",
+ "END:VEVENT",
+ ),
+ )
+
+ assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
+ }
+
+ @Test
+ fun `an all-day reminder rounds to whole days before`() {
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Anna Birthday",
+ "DTSTART;VALUE=DATE:20260819",
+ "DTEND;VALUE=DATE:20260820",
+ "BEGIN:VALARM",
+ "ACTION:DISPLAY",
+ // 15h before midnight == "1 day before at 09:00".
+ "TRIGGER:-P0DT15H0M0S",
+ "END:VALARM",
+ "END:VEVENT",
+ ),
+ )
+
+ assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
+ }
+
+ @Test
+ fun `a timed reminder keeps its exact lead time`() {
+ // Their P0DT1H5M0S duration spelling, from Parser.getDurationCode.
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Standup",
+ "DTSTART:20260819T080000Z",
+ "DTEND:20260819T081500Z",
+ "BEGIN:VALARM",
+ "ACTION:DISPLAY",
+ "TRIGGER:-P0DT0H10M0S",
+ "END:VALARM",
+ "END:VEVENT",
+ ),
+ )
+
+ assertThat(result.events.single().semanticReminderMinutes()).containsExactly(10)
+ }
+
+ @Test
+ fun `a timed trigger that fires after the start is dropped, not clamped`() {
+ // A legal RFC 5545 follow-up alarm. Modelling it as a lead time of zero
+ // would invent a notification at the start the file never asked for.
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Standup",
+ "DTSTART:20260819T080000Z",
+ "DTEND:20260819T081500Z",
+ "BEGIN:VALARM",
+ "ACTION:DISPLAY",
+ "TRIGGER:PT30M",
+ "END:VALARM",
+ "END:VEVENT",
+ ),
+ )
+
+ assertThat(result.events.single().semanticReminderMinutes()).isEmpty()
+ }
+
+ @Test
+ fun `an all-day series' EXDATE day codes carry over`() {
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Standup",
+ "DTSTART;VALUE=DATE:20260819",
+ "DTEND;VALUE=DATE:20260820",
+ "RRULE:FREQ=DAILY;INTERVAL=1",
+ "EXDATE:20260820",
+ "EXDATE:20260822",
+ "END:VEVENT",
+ ),
+ )
+
+ assertThat(result.events.single().exDates).containsExactly("20260820", "20260822").inOrder()
+ }
+
+ @Test
+ fun `an all-day EXDATE keeps the day it spells out, whatever zone it carries`() {
+ // A midnight east of UTC resolves to an instant on the previous day;
+ // reading that back in UTC would exclude a day that isn't an occurrence
+ // and leave the one the user deleted in place.
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Standup",
+ "DTSTART;VALUE=DATE:20260819",
+ "DTEND;VALUE=DATE:20260820",
+ "RRULE:FREQ=DAILY",
+ "EXDATE;TZID=Europe/Berlin:20260821T000000",
+ "EXDATE;VALUE=DATE:20260823",
+ "END:VEVENT",
+ ),
+ )
+
+ assertThat(result.events.single().exDates)
+ .containsExactly("20260821", "20260823").inOrder()
+ }
+
+ @Test
+ fun `a timed series' bare EXDATE day code is resolved against the series time`() {
+ // They store excluded occurrences as day codes and write them out that
+ // way even for a timed series, where the property alone is ambiguous.
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Gym",
+ "DTSTART:20260819T170000Z",
+ "DTEND:20260819T180000Z",
+ "RRULE:FREQ=DAILY;INTERVAL=1",
+ "EXDATE:20260820",
+ "END:VEVENT",
+ ),
+ )
+
+ assertThat(result.events.single().exDates).containsExactly("20260820T170000Z")
+ }
+
+ @Test
+ fun `a floating EXDATE is read in the series' zone, not the device's`() {
+ // RFC 5545 ties EXDATE to DTSTART's form: with no Z and no TZID the
+ // value is New York wall time. Reading it in the device's Berlin zone
+ // would land on an instant no occurrence has, and the excluded
+ // occurrence would come back.
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Standup",
+ "DTSTART;TZID=America/New_York:20260819T100000",
+ "DTEND;TZID=America/New_York:20260819T103000",
+ "RRULE:FREQ=WEEKLY",
+ "EXDATE:20260826T100000",
+ "END:VEVENT",
+ ),
+ )
+
+ assertThat(result.events.single().exDates).containsExactly("20260826T140000Z")
+ }
+
+ @Test
+ fun `EXDATE is dropped along with an unusable recurrence rule`() {
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Gym",
+ "DTSTART:20260819T170000Z",
+ "DTEND:20260819T180000Z",
+ "RRULE:INTERVAL=1",
+ "EXDATE:20260820",
+ "END:VEVENT",
+ ),
+ )
+
+ val event = result.events.single()
+ assertThat(event.recurrenceRule).isNull()
+ assertThat(event.exDates).isEmpty()
+ }
+
+ @Test
+ fun `a whole export imports every component`() {
+ val result = parser.parse(
+ fossify(
+ "BEGIN:VEVENT",
+ "SUMMARY:Anna Birthday",
+ "UID:abc123",
+ "X-FOSSIFY-CATEGORY-COLOR:-1155931",
+ "CATEGORIES:Birthdays",
+ "LAST-MODIFIED:20260101T120000Z",
+ "TRANSP:TRANSPARENT",
+ "DTSTART;VALUE=DATE:19900412",
+ "DTEND;VALUE=DATE:19900413",
+ "X-FOSSIFY-MISSING-YEAR:0",
+ "DTSTAMP:20260819T100000Z",
+ "CLASS:PUBLIC",
+ "STATUS:CONFIRMED",
+ "RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
+ "END:VEVENT",
+ "BEGIN:VEVENT",
+ "SUMMARY:Standup",
+ "UID:def456",
+ "DTSTART:20260819T080000Z",
+ "DTEND:20260819T081500Z",
+ "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU",
+ "END:VEVENT",
+ "BEGIN:VTODO",
+ "SUMMARY:Pay rent",
+ "UID:ghi789",
+ "DTSTART;VALUE=DATE:20260901",
+ "END:VTODO",
+ ),
+ )
+
+ assertThat(result.events.map { it.summary })
+ .containsExactly("Anna Birthday", "Standup", "Pay rent").inOrder()
+ // Nothing may reach the provider as a zero-length all-day row.
+ assertThat(result.events.none { it.isAllDay && days(it) == 0L }).isTrue()
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt
index abdc710..851d427 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt
@@ -165,4 +165,36 @@ class IcsParserTest {
assertThat(result.events.map { it.uid }).containsExactly("good")
assertThat(result.warnings).contains(IcsParseWarning.EventWithoutStartSkipped)
}
+
+ @Test
+ fun `round-trips a timed series' deleted occurrences`() {
+ val event = IcsEvent(
+ uid = "u20@calendula",
+ summary = "Weekly",
+ start = instantUtc(2026, 6, 18, 13, 0),
+ end = instantUtc(2026, 6, 18, 13, 30),
+ isAllDay = false,
+ zoneId = "UTC",
+ recurrenceRule = "FREQ=WEEKLY",
+ exDates = listOf("20260625T130000Z"),
+ )
+
+ assertThat(roundTrip(event).exDates).containsExactly("20260625T130000Z")
+ }
+
+ @Test
+ fun `round-trips an all-day series' deleted occurrences`() {
+ val event = IcsEvent(
+ uid = "u21@calendula",
+ summary = "Holiday",
+ start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC),
+ end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC),
+ isAllDay = true,
+ zoneId = "UTC",
+ recurrenceRule = "FREQ=YEARLY",
+ exDates = listOf("20270618"),
+ )
+
+ assertThat(roundTrip(event).exDates).containsExactly("20270618")
+ }
}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrenceTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrenceTest.kt
new file mode 100644
index 0000000..6284708
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrenceTest.kt
@@ -0,0 +1,166 @@
+package de.jeanlucmakiola.calendula.domain.ics
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+
+class IcsRecurrenceTest {
+
+ @Test
+ fun `a well-formed rule is unchanged`() {
+ val result = sanitizeRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
+
+ assertThat(result.rule).isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
+ assertThat(result.repaired).isFalse()
+ }
+
+ @Test
+ fun `the RRULE prefix is stripped`() {
+ assertThat(sanitizeRrule("RRULE:FREQ=DAILY").rule).isEqualTo("FREQ=DAILY")
+ }
+
+ @Test
+ fun `an empty list part is dropped`() {
+ val bareByDay = sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY=")
+ assertThat(bareByDay.rule).isEqualTo("FREQ=WEEKLY;INTERVAL=1")
+ assertThat(bareByDay.repaired).isTrue()
+
+ assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=MO,,WE;BYMONTH=").rule)
+ .isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE")
+ }
+
+ @Test
+ fun `a nonsensical INTERVAL or COUNT is dropped, not fatal`() {
+ assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=0").rule).isEqualTo("FREQ=DAILY")
+ assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=every").rule).isEqualTo("FREQ=DAILY")
+ assertThat(sanitizeRrule("FREQ=DAILY;COUNT=0").rule).isEqualTo("FREQ=DAILY")
+ }
+
+ @Test
+ fun `a rule without a usable FREQ is unsalvageable`() {
+ assertThat(sanitizeRrule("INTERVAL=1;BYDAY=MO").rule).isNull()
+ assertThat(sanitizeRrule("FREQ=FORTNIGHTLY;INTERVAL=1").rule).isNull()
+ assertThat(sanitizeRrule("FREQ=;INTERVAL=1").rule).isNull()
+ assertThat(sanitizeRrule("").rule).isNull()
+ assertThat(sanitizeRrule(null).rule).isNull()
+ }
+
+ @Test
+ fun `no rule at all is not a repair`() {
+ assertThat(sanitizeRrule(null).repaired).isFalse()
+ assertThat(sanitizeRrule("").repaired).isFalse()
+ assertThat(sanitizeRrule("FREQ=FORTNIGHTLY").repaired).isTrue()
+ }
+
+ @Test
+ fun `normalising is not repairing`() {
+ // Case and a trailing separator change the text but lose nothing, and
+ // telling the user their file was faulty over either is crying wolf.
+ val lowercase = sanitizeRrule("freq=daily;count=3")
+ assertThat(lowercase.rule).isEqualTo("FREQ=DAILY;COUNT=3")
+ assertThat(lowercase.repaired).isFalse()
+
+ val trailing = sanitizeRrule("FREQ=DAILY;")
+ assertThat(trailing.rule).isEqualTo("FREQ=DAILY")
+ assertThat(trailing.repaired).isFalse()
+ }
+
+ @Test
+ fun `parts the provider's parser has no entry for are dropped`() {
+ // EventRecurrence.parse throws on any part name outside its own table,
+ // so passing one on would cost the whole event (Codeberg #225).
+ val result = sanitizeRrule("FREQ=MONTHLY;RSCALE=GREGORIAN;BYMONTHDAY=15")
+
+ assertThat(result.rule).isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15")
+ assertThat(result.repaired).isTrue()
+ assertThat(sanitizeRrule("FREQ=YEARLY;X-THING=7").rule).isEqualTo("FREQ=YEARLY")
+ }
+
+ @Test
+ fun `WKST survives, a nonsense weekday does not`() {
+ assertThat(sanitizeRrule("FREQ=WEEKLY;WKST=SU").rule).isEqualTo("FREQ=WEEKLY;WKST=SU")
+ assertThat(sanitizeRrule("FREQ=WEEKLY;WKST=XX").rule).isEqualTo("FREQ=WEEKLY")
+ }
+
+ @Test
+ fun `a BYDAY item that isn't a weekday is dropped`() {
+ assertThat(sanitizeRrule("FREQ=MONTHLY;BYDAY=-1FR,1,MO").rule)
+ .isEqualTo("FREQ=MONTHLY;BYDAY=-1FR,MO")
+ assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=mo,tu").rule)
+ .isEqualTo("FREQ=WEEKLY;BYDAY=MO,TU")
+ }
+
+ @Test
+ fun `a zero is dropped from the by-position parts only`() {
+ assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=0,15").rule)
+ .isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15")
+ assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=0,9").rule)
+ .isEqualTo("FREQ=DAILY;BYHOUR=0,9")
+ }
+
+ @Test
+ fun `an out-of-range numeric item is dropped`() {
+ // EventRecurrence.parseNumberList range-checks every one of these and
+ // throws out of insert, so being a number isn't enough (Codeberg #225).
+ assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=15,32").rule)
+ .isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15")
+ assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=25").rule).isEqualTo("FREQ=DAILY")
+ assertThat(sanitizeRrule("FREQ=YEARLY;BYMONTH=13").rule).isEqualTo("FREQ=YEARLY")
+ assertThat(sanitizeRrule("FREQ=YEARLY;BYWEEKNO=60").rule).isEqualTo("FREQ=YEARLY")
+ assertThat(sanitizeRrule("FREQ=YEARLY;BYYEARDAY=400").rule).isEqualTo("FREQ=YEARLY")
+ assertThat(sanitizeRrule("FREQ=DAILY;BYSECOND=60").rule).isEqualTo("FREQ=DAILY")
+ assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=25").repaired).isTrue()
+ }
+
+ @Test
+ fun `the edges of each range survive`() {
+ assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=-31,31").repaired).isFalse()
+ assertThat(sanitizeRrule("FREQ=YEARLY;BYYEARDAY=-366,366;BYWEEKNO=-53,53").repaired)
+ .isFalse()
+ assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=23;BYMINUTE=59;BYSECOND=59").repaired)
+ .isFalse()
+ // BYSETPOS is the one part the provider leaves unbounded.
+ assertThat(sanitizeRrule("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=-1,400").rule)
+ .isEqualTo("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=-1,400")
+ }
+
+ @Test
+ fun `an UNTIL that isn't a timestamp is dropped`() {
+ assertThat(sanitizeRrule("FREQ=DAILY;UNTIL=20260819T235959Z").rule)
+ .isEqualTo("FREQ=DAILY;UNTIL=20260819T235959Z")
+ assertThat(sanitizeRrule("FREQ=DAILY;UNTIL=2026-08-19").rule).isEqualTo("FREQ=DAILY")
+ }
+
+ @Test
+ fun `UNTIL and COUNT together keep UNTIL`() {
+ // EventRecurrence.parse throws outright on a rule carrying both.
+ val both = sanitizeRrule("FREQ=WEEKLY;COUNT=10;UNTIL=20260101T000000Z")
+
+ assertThat(both.rule).isEqualTo("FREQ=WEEKLY;UNTIL=20260101T000000Z")
+ assertThat(both.repaired).isTrue()
+ }
+
+ @Test
+ fun `a COUNT the UNTIL check never sees is left alone`() {
+ // An UNTIL dropped for its shape doesn't take the COUNT down with it.
+ val staleUntil = sanitizeRrule("FREQ=WEEKLY;COUNT=10;UNTIL=2026-01-01")
+
+ assertThat(staleUntil.rule).isEqualTo("FREQ=WEEKLY;COUNT=10")
+ assertThat(staleUntil.repaired).isTrue()
+ }
+
+ @Test
+ fun `a part without a value separator is reported as repaired`() {
+ val noEquals = sanitizeRrule("FREQ=WEEKLY;BYDAY")
+
+ assertThat(noEquals.rule).isEqualTo("FREQ=WEEKLY")
+ assertThat(noEquals.repaired).isTrue()
+ }
+
+ @Test
+ fun `a trailing separator is normalisation, not a repair`() {
+ val trailing = sanitizeRrule("FREQ=DAILY;")
+
+ assertThat(trailing.rule).isEqualTo("FREQ=DAILY")
+ assertThat(trailing.repaired).isFalse()
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriterTest.kt
index 89b06e7..bc92cfb 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriterTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriterTest.kt
@@ -149,4 +149,55 @@ class IcsWriterTest {
// Stable across calls — a re-export of the same row yields the same UID.
assertThat(deriveIcsUid(null, 7, 1000)).isEqualTo(deriveIcsUid(null, 7, 1000))
}
+
+ @Test
+ fun `a timed series writes its exclusions as UTC stamps`() {
+ val event = IcsEvent(
+ uid = "u9@calendula",
+ summary = "Weekly",
+ start = instantUtc(2026, 6, 18, 13, 0),
+ end = instantUtc(2026, 6, 18, 13, 30),
+ isAllDay = false,
+ zoneId = "UTC",
+ recurrenceRule = "FREQ=WEEKLY",
+ exDates = listOf("20260625T130000Z", "20260702T130000Z"),
+ )
+
+ assertThat(lines(listOf(event)))
+ .contains("EXDATE:20260625T130000Z,20260702T130000Z")
+ }
+
+ @Test
+ fun `an all-day series marks its exclusions VALUE=DATE`() {
+ val start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC)
+ val event = IcsEvent(
+ uid = "u10@calendula",
+ summary = "Holiday",
+ start = start,
+ end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC),
+ isAllDay = true,
+ zoneId = "UTC",
+ recurrenceRule = "FREQ=YEARLY",
+ exDates = listOf("20270618"),
+ )
+
+ // RFC 5545 ties EXDATE's value type to DTSTART's; a bare day code
+ // without VALUE=DATE reads as a malformed DATE-TIME.
+ assertThat(lines(listOf(event))).contains("EXDATE;VALUE=DATE:20270618")
+ }
+
+ @Test
+ fun `a one-off event never writes an EXDATE`() {
+ val event = IcsEvent(
+ uid = "u11@calendula",
+ summary = "Standup",
+ start = instantUtc(2026, 6, 18, 13, 0),
+ end = instantUtc(2026, 6, 18, 13, 30),
+ isAllDay = false,
+ zoneId = "UTC",
+ exDates = listOf("20260625T130000Z"),
+ )
+
+ assertThat(lines(listOf(event)).none { it.startsWith("EXDATE") }).isTrue()
+ }
}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt
index c29a521..a22c6f2 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt
@@ -286,9 +286,53 @@ class RescheduleViewModelTest {
vm.move(toWednesday())
advanceUntilIdle()
+ val abandoned = vm.abandoned.value
assertThat(vm.undo(undo)).isFalse()
advanceUntilIdle()
assertThat(fake.updatedEvents).hasSize(1)
+ // Nothing ticks: the refusal started no journey of its own, and the
+ // counter is global — a tick here would release the copy the drop that
+ // *is* in flight is holding, before the grid has drawn it.
+ assertThat(vm.abandoned.value).isEqualTo(abandoned)
+ }
+
+ @Test
+ fun `an undo that fails lets the held chip go`(@TempDir tempDir: Path) = runTest(dispatcher) {
+ // undoStarted has already sent the chip back on its journey by the time
+ // the inverse write is attempted. Without a tick here nothing ever seats
+ // it, and the overlay waits out its whole settle timeout for a chip that
+ // is never coming.
+ val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
+ val vm = viewModel(tempDir, fake)
+ vm.move(oneHourLater())
+ advanceUntilIdle()
+ val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
+
+ val abandoned = vm.abandoned.value
+ fake.writeError = SecurityException("permission revoked")
+ assertThat(vm.undo(undo)).isTrue()
+ advanceUntilIdle()
+
+ assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
+ assertThat(vm.abandoned.value).isEqualTo(abandoned + 1)
+ }
+
+ @Test
+ fun `an undo that lands leaves the abandoned count alone`(
+ @TempDir tempDir: Path,
+ ) = runTest(dispatcher) {
+ val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
+ val vm = viewModel(tempDir, fake)
+ vm.move(oneHourLater())
+ advanceUntilIdle()
+ val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
+
+ val abandoned = vm.abandoned.value
+ vm.undo(undo)
+ advanceUntilIdle()
+
+ assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Undone)
+ assertThat(vm.abandoned.value).isEqualTo(abandoned)
}
@Test
@@ -494,4 +538,61 @@ class RescheduleViewModelTest {
assertThat(fake.updatedEvents).isEmpty()
assertThat(fake.updatedOccurrences).isEmpty()
}
+
+ @Test
+ fun `a drop that lands nothing says so, since move() already answered yes`(
+ @TempDir tempDir: Path,
+ ) = runTest(dispatcher) {
+ val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
+ val vm = viewModel(tempDir, fake)
+
+ // Refused in prepare: nowhere to move to.
+ assertThat(vm.move(
+ MoveRequest(
+ eventId = 42L,
+ beginMillis = beginMillis,
+ endMillis = endMillis,
+ target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
+ ),
+ )).isTrue()
+ advanceUntilIdle()
+ assertThat(vm.abandoned.value).isEqualTo(1)
+
+ // Refused by the provider, after the write was attempted.
+ fake.writeError = SecurityException("revoked")
+ vm.move(oneHourLater())
+ advanceUntilIdle()
+ assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
+ assertThat(vm.abandoned.value).isEqualTo(2)
+ }
+
+ @Test
+ fun `a scope dialog dismissed leaves nothing waiting on the write`(
+ @TempDir tempDir: Path,
+ ) = runTest(dispatcher) {
+ val fake = FakeCalendarDataSource().apply {
+ eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
+ }
+ val vm = viewModel(tempDir, fake)
+
+ vm.move(oneHourLater())
+ advanceUntilIdle()
+ assertThat(vm.abandoned.value).isEqualTo(0)
+
+ vm.cancelScope()
+ advanceUntilIdle()
+ assertThat(vm.abandoned.value).isEqualTo(1)
+ }
+
+ @Test
+ fun `a written move never counts as abandoned`(@TempDir tempDir: Path) = runTest(dispatcher) {
+ val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
+ val vm = viewModel(tempDir, fake)
+
+ vm.move(oneHourLater())
+ advanceUntilIdle()
+
+ assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
+ assertThat(vm.abandoned.value).isEqualTo(0)
+ }
}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt
new file mode 100644
index 0000000..1e4fc48
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt
@@ -0,0 +1,209 @@
+package de.jeanlucmakiola.calendula.ui.common
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
+import de.jeanlucmakiola.calendula.ui.week.layoutDay
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.atTime
+import kotlinx.datetime.plus
+import kotlinx.datetime.toInstant
+import androidx.compose.foundation.shape.CornerSize
+import androidx.compose.ui.unit.dp
+import org.junit.jupiter.api.Test
+
+/**
+ * Which edges a timed block is cut on, and how much of a dragged piece its day
+ * can show (#253).
+ */
+class TimedBlockShapeTest {
+
+ private val zone = TimeZone.UTC
+ private val day1 = LocalDate(2026, 9, 5)
+ private val day2 = day1.plus(1, DateTimeUnit.DAY)
+ private val day3 = day2.plus(1, DateTimeUnit.DAY)
+
+ private fun event(from: LocalDateTimeIsh, to: LocalDateTimeIsh) = EventInstance(
+ instanceId = 1L, eventId = 1L, calendarId = 1L, title = "E",
+ start = from.date.atTime(from.hour, 0).toInstant(zone),
+ end = to.date.atTime(to.hour, 0).toInstant(zone),
+ isAllDay = false, color = 0xFF112233.toInt(), location = null,
+ )
+
+ data class LocalDateTimeIsh(val date: LocalDate, val hour: Int)
+
+ private fun at(date: LocalDate, hour: Int) = LocalDateTimeIsh(date, hour)
+
+ @Test
+ fun `a same-day block is cut on neither edge`() {
+ val ev = event(at(day1, 9), at(day1, 10))
+ val block = layoutDay(listOf(ev), day1, zone).single()
+
+ assertThat(block.continuesBefore(day1, zone)).isFalse()
+ assertThat(block.continuesAfter(day1, zone)).isFalse()
+ }
+
+ @Test
+ fun `an overnight event is cut at the foot of its first day and the head of its second`() {
+ val ev = event(at(day1, 20), at(day2, 8))
+
+ val head = layoutDay(listOf(ev), day1, zone).single()
+ assertThat(head.continuesBefore(day1, zone)).isFalse()
+ assertThat(head.continuesAfter(day1, zone)).isTrue()
+
+ val tail = layoutDay(listOf(ev), day2, zone).single()
+ assertThat(tail.continuesBefore(day2, zone)).isTrue()
+ assertThat(tail.continuesAfter(day2, zone)).isFalse()
+ }
+
+ @Test
+ fun `a day wholly inside a long event is cut on both edges`() {
+ val ev = event(at(day1, 20), at(day3, 8))
+ val middle = layoutDay(listOf(ev), day2, zone).single()
+
+ assertThat(middle.continuesBefore(day2, zone)).isTrue()
+ assertThat(middle.continuesAfter(day2, zone)).isTrue()
+ }
+
+ @Test
+ fun `an event ending exactly at midnight is not cut`() {
+ val ev = event(at(day1, 20), at(day2, 0))
+ val block = layoutDay(listOf(ev), day1, zone).single()
+
+ assertThat(block.continuesAfter(day1, zone)).isFalse()
+ }
+
+ @Test
+ fun `only the cut edges lose their corners`() {
+ val square = 0.dp
+ assertThat(timedBlockShape(continuesBefore = true, continuesAfter = false).topStart)
+ .isEqualTo(CornerSize(square))
+ assertThat(timedBlockShape(continuesBefore = true, continuesAfter = false).bottomStart)
+ .isEqualTo(CornerSize(EVENT_CHIP_CORNER))
+ assertThat(timedBlockShape(continuesBefore = false, continuesAfter = true).topStart)
+ .isEqualTo(CornerSize(EVENT_CHIP_CORNER))
+ assertThat(timedBlockShape(continuesBefore = false, continuesAfter = true).bottomEnd)
+ .isEqualTo(CornerSize(square))
+ }
+
+ private val fourHours = 4 * 60
+
+ @Test
+ fun `an event that fits its day is drawn as one uncut piece`() {
+ val pieces = dragSlices(eventStartMin = 10 * 60, spanMin = fourHours)
+
+ assertThat(pieces).hasSize(1)
+ assertThat(pieces.single().dayOffset).isEqualTo(0)
+ assertThat(pieces.single().spanMin).isEqualTo(fourHours)
+ assertThat(pieces.single().continuesBefore).isFalse()
+ assertThat(pieces.single().continuesAfter).isFalse()
+ }
+
+ @Test
+ fun `dragging the start half re-splits both days as it moves`() {
+ // Held at 22:00: two hours on this day, two on the next.
+ assertThat(dragSlices(22 * 60, fourHours).map { it.spanMin })
+ .containsExactly(2 * 60, 2 * 60).inOrder()
+ // Carried up to 21:00 the first day takes three, so the second day's end
+ // comes up by exactly the hour the first day gained.
+ assertThat(dragSlices(21 * 60, fourHours).map { it.spanMin })
+ .containsExactly(3 * 60, 60).inOrder()
+ }
+
+ @Test
+ fun `dragging the end half splits the same way, one day back`() {
+ // The tail's top edge is at midnight and the event began two hours
+ // earlier, so it starts at -120 relative to the day the finger is on.
+ val pieces = dragSlices(eventStartMin = -2 * 60, spanMin = fourHours)
+
+ assertThat(pieces.map { it.dayOffset }).containsExactly(-1, 0).inOrder()
+ assertThat(pieces.map { it.spanMin }).containsExactly(2 * 60, 2 * 60).inOrder()
+ // The piece on the earlier day ends at midnight; the held one starts there.
+ assertThat(pieces.first().startMin).isEqualTo(22 * 60)
+ assertThat(pieces.first().continuesAfter).isTrue()
+ assertThat(pieces.last().startMin).isEqualTo(0)
+ assertThat(pieces.last().continuesBefore).isTrue()
+ }
+
+ @Test
+ fun `the earlier half shrinks away as the end half is pulled down its own day`() {
+ // Tail top dragged from midnight down to 01:00: one hour left behind.
+ assertThat(dragSlices(-2 * 60 + 60, fourHours).map { it.spanMin })
+ .containsExactly(60, 3 * 60).inOrder()
+ // At 02:00 the event no longer crosses midnight at all.
+ val whole = dragSlices(0, fourHours)
+ assertThat(whole).hasSize(1)
+ assertThat(whole.single().continuesBefore).isFalse()
+ }
+
+ @Test
+ fun `a day wholly inside a long event is cut on both sides`() {
+ val pieces = dragSlices(eventStartMin = 22 * 60, spanMin = 30 * 60)
+
+ assertThat(pieces.map { it.dayOffset }).containsExactly(0, 1, 2).inOrder()
+ assertThat(pieces[1].spanMin).isEqualTo(MINUTES_PER_DAY)
+ assertThat(pieces[1].continuesBefore).isTrue()
+ assertThat(pieces[1].continuesAfter).isTrue()
+ }
+
+ @Test
+ fun `an event ending exactly at midnight takes no piece of the next day`() {
+ val pieces = dragSlices(eventStartMin = 22 * 60, spanMin = 2 * 60)
+
+ assertThat(pieces).hasSize(1)
+ assertThat(pieces.single().continuesAfter).isFalse()
+ }
+
+ @Test
+ fun `a zero-length event still has a piece to draw`() {
+ val pieces = dragSlices(eventStartMin = 9 * 60, spanMin = 0)
+
+ assertThat(pieces).hasSize(1)
+ assertThat(pieces.single().dayOffset).isEqualTo(0)
+ assertThat(pieces.single().startMin).isEqualTo(9 * 60)
+ assertThat(pieces.single().spanMin).isEqualTo(0)
+ }
+
+ @Test
+ fun `only the days there are columns for are sliced`() {
+ // A week's worth of event, previewed on a timeline showing three days
+ // from the one the finger is on.
+ val pieces = dragSlices(0, 7 * MINUTES_PER_DAY, within = 0..2)
+
+ assertThat(pieces.map { it.dayOffset }).containsExactly(0, 1, 2).inOrder()
+ assertThat(pieces.last().continuesAfter).isTrue()
+ }
+
+ @Test
+ fun `an event outside the columns shown is sliced into nothing`() {
+ assertThat(dragSlices(-3 * MINUTES_PER_DAY, 60, within = 0..2)).isEmpty()
+ }
+
+ // --- the copy never blinks out from under the finger (#253) ---
+
+ @Test
+ fun `a short tail dragged to its floor still has a piece to draw`() {
+ // 23:50-00:05: on the tail's day the clip offset is 10 and the floor is
+ // one snap step above midnight, which puts the whole event on the day
+ // before. Day view has no column for that day, so dragSlices hands back
+ // nothing and the floating copy used to vanish mid-gesture.
+ val span = 15
+ val floor = dragFloorMin(clipOffsetMin = 10, eventSpanMin = span)
+ val eventStartMin = floor - 10
+ val visible = 0..0
+
+ assertThat(dragSlices(eventStartMin, span, visible)).isEmpty()
+ val edge = edgeDragSlice(eventStartMin, span, visible)
+ assertThat(edge).isNotNull()
+ assertThat(edge!!.dayOffset).isEqualTo(0)
+ assertThat(edge.startMin).isEqualTo(0)
+ assertThat(edge.continuesBefore).isTrue()
+ }
+
+ @Test
+ fun `an event a visible column does hold gets no edge piece`() {
+ assertThat(edgeDragSlice(9 * 60, 60, 0..0)).isNull()
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt
new file mode 100644
index 0000000..9ee1646
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt
@@ -0,0 +1,156 @@
+package de.jeanlucmakiola.calendula.ui.common
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.ui.week.TimedBlock
+import de.jeanlucmakiola.calendula.ui.week.layoutDay
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.atTime
+import kotlinx.datetime.plus
+import kotlinx.datetime.toInstant
+import org.junit.jupiter.api.Test
+
+/**
+ * What a timeline drop asks for, for the head and the tail of an event that
+ * crosses midnight (#253). The tail's top edge is midnight rather than the
+ * event's start, so the drop has to take that offset back off.
+ */
+class TimelineDropTest {
+
+ private val zone = TimeZone.UTC
+ private val sat = LocalDate(2026, 9, 5)
+ private val sun = sat.plus(1, DateTimeUnit.DAY)
+
+ // Sat 5 Sep 20:00 to Sun 6 Sep 08:00.
+ private val overnight = EventInstance(
+ instanceId = 1L,
+ eventId = 1L,
+ calendarId = 1L,
+ title = "Night shift",
+ start = sat.atTime(20, 0).toInstant(zone),
+ end = sun.atTime(8, 0).toInstant(zone),
+ isAllDay = false,
+ color = 0xFF112233.toInt(),
+ location = null,
+ )
+
+ private fun blockOn(day: LocalDate): TimedBlock =
+ layoutDay(listOf(overnight), day, zone).single()
+
+ @Test
+ fun `the head block carries no clip offset`() {
+ val head = blockOn(sat)
+ assertThat(head.startMin).isEqualTo(20 * 60)
+ assertThat(head.clipOffsetMinutes(sat, zone)).isEqualTo(0)
+ }
+
+ @Test
+ fun `the tail block is offset by the part that already ran`() {
+ val tail = blockOn(sun)
+ assertThat(tail.startMin).isEqualTo(0)
+ // 20:00 to midnight is four hours of the event already gone.
+ assertThat(tail.clipOffsetMinutes(sun, zone)).isEqualTo(4 * 60)
+ }
+
+ @Test
+ fun `dropping the head asks for exactly where it landed`() {
+ val target = LocalDate(2026, 9, 2)
+ val drop = TimelineDrop(overnight, target, 20 * 60, clipOffsetMin = 0)
+
+ assertThat(drop.startInstant(zone)).isEqualTo(target.atTime(20, 0).toInstant(zone))
+ }
+
+ @Test
+ fun `dropping the tail moves the event by the days the tail travelled`() {
+ val tail = blockOn(sun)
+ val offset = tail.clipOffsetMinutes(sun, zone)
+ // Dragged three days back, same height in the column: Sun 6 -> Thu 3.
+ val drop = TimelineDrop(overnight, LocalDate(2026, 9, 3), 0, offset)
+
+ // The event began Sat 5 Sep 20:00, so it now begins Wed 2 Sep 20:00 —
+ // the whole event moved three days, and no time of day was invented.
+ assertThat(drop.startInstant(zone))
+ .isEqualTo(LocalDate(2026, 9, 2).atTime(20, 0).toInstant(zone))
+ }
+
+ @Test
+ fun `dragging the tail down the column re-times the event across midnight`() {
+ val offset = blockOn(sun).clipOffsetMinutes(sun, zone)
+ // Tail top pulled from 00:00 to 03:00 on its own day.
+ val drop = TimelineDrop(overnight, sun, 3 * 60, offset)
+
+ assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(23, 0).toInstant(zone))
+ }
+
+ @Test
+ fun `pulling the tail above its own midnight moves the event earlier`() {
+ val offset = blockOn(sun).clipOffsetMinutes(sun, zone)
+ // The tail's top edge dragged an hour above the midnight it rests on.
+ val drop = TimelineDrop(overnight, sun, -60, offset)
+
+ // Event was Sat 20:00 to Sun 08:00; it now begins an hour earlier.
+ assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(19, 0).toInstant(zone))
+ }
+
+ @Test
+ fun `a top edge dragged past the next midnight resolves onto the day after`() {
+ val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0)
+
+ assertThat(drop.startInstant(zone)).isEqualTo(sun.atTime(1, 0).toInstant(zone))
+ }
+
+ @Test
+ fun `a short tail can still be dragged a step earlier`() {
+ // 23:50 to 00:05: five minutes of tail, less than one snap step, which
+ // a floor pinned at midnight left with nowhere to go (#253).
+ assertThat(dragFloorMin(clipOffsetMin = 10, eventSpanMin = 15))
+ .isEqualTo(-DRAG_SNAP_MINUTES)
+ }
+
+ @Test
+ fun `a long tail may rise until its event ends just inside the day`() {
+ // Sat 20:00 to Sun 08:00, held by the Sunday half.
+ assertThat(dragFloorMin(clipOffsetMin = 4 * 60, eventSpanMin = 12 * 60))
+ .isEqualTo(DRAG_SNAP_MINUTES - 8 * 60)
+ }
+
+ @Test
+ fun `an unclipped block cannot be dragged above its own midnight`() {
+ assertThat(dragFloorMin(clipOffsetMin = 0, eventSpanMin = 60)).isEqualTo(0)
+ }
+
+ @Test
+ fun `a drop across a DST boundary keeps the time of day it was let go at`() {
+ // Europe/Berlin springs forward at 02:00 on 29 March 2026, between the
+ // event's start and the midnight its tail is cut at.
+ val berlin = TimeZone.of("Europe/Berlin")
+ val sun = LocalDate(2026, 3, 29)
+ val overnightIntoDst = EventInstance(
+ instanceId = 3L, eventId = 3L, calendarId = 1L, title = "Night shift",
+ start = LocalDate(2026, 3, 28).atTime(22, 0).toInstant(berlin),
+ end = sun.atTime(6, 0).toInstant(berlin),
+ isAllDay = false, color = 0xFF112233.toInt(), location = null,
+ )
+ val tail = layoutDay(listOf(overnightIntoDst), sun, berlin).single()
+ val offset = tail.clipOffsetMinutes(sun, berlin)
+ // Dropped back onto its own slot a week earlier, clear of the boundary.
+ val drop = TimelineDrop(overnightIntoDst, LocalDate(2026, 3, 22), 0, offset)
+
+ assertThat(drop.startInstant(berlin))
+ .isEqualTo(LocalDate(2026, 3, 21).atTime(22, 0).toInstant(berlin))
+ }
+
+ @Test
+ fun `a block that is not clipped is unaffected wherever it sits`() {
+ val plain = EventInstance(
+ instanceId = 2L, eventId = 2L, calendarId = 1L, title = "Standup",
+ start = sat.atTime(9, 0).toInstant(zone),
+ end = sat.atTime(10, 0).toInstant(zone),
+ isAllDay = false, color = 0xFF112233.toInt(), location = null,
+ )
+ val block = layoutDay(listOf(plain), sat, zone).single()
+ assertThat(block.clipOffsetMinutes(sat, zone)).isEqualTo(0)
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/imports/ImportTargetTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/imports/ImportTargetTest.kt
new file mode 100644
index 0000000..cbb1a5b
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/imports/ImportTargetTest.kt
@@ -0,0 +1,89 @@
+package de.jeanlucmakiola.calendula.ui.imports
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.calendula.domain.CalendarSource
+import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
+import org.junit.jupiter.api.Test
+import kotlin.time.Instant
+
+/** Which calendar a bulk import lands on before the user touches the picker. */
+class ImportTargetTest {
+
+ private fun cal(id: Long, name: String, local: Boolean = false) = CalendarSource(
+ id = id,
+ displayName = name,
+ accountName = "acc@local",
+ accountType = if (local) "LOCAL" else "com.google",
+ color = 0,
+ isVisibleInSystem = true,
+ isLocal = local,
+ )
+
+ private val calendars = listOf(
+ cal(1L, "Work"),
+ cal(2L, "Personal", local = true),
+ cal(3L, "Birthdays", local = true),
+ )
+
+ @Test
+ fun `a calendar named like the file's wins`() {
+ assertThat(defaultImportTarget(calendars, "Birthdays")).isEqualTo(3L)
+ }
+
+ @Test
+ fun `the name match ignores case`() {
+ assertThat(defaultImportTarget(calendars, "birthdays")).isEqualTo(3L)
+ }
+
+ @Test
+ fun `an unmatched name falls back to the first local calendar`() {
+ assertThat(defaultImportTarget(calendars, "Holidays")).isEqualTo(2L)
+ }
+
+ @Test
+ fun `no name at all falls back to the first local calendar`() {
+ assertThat(defaultImportTarget(calendars, null)).isEqualTo(2L)
+ }
+
+ @Test
+ fun `with no local calendars the first of any kind is taken`() {
+ assertThat(defaultImportTarget(listOf(cal(1L, "Work")), null)).isEqualTo(1L)
+ }
+
+ @Test
+ fun `an empty list preselects nothing`() {
+ assertThat(defaultImportTarget(emptyList(), "Birthdays")).isNull()
+ }
+
+ private fun event(calendarName: String?) = ParsedIcsEvent(
+ uid = "u@x",
+ summary = "E",
+ start = Instant.fromEpochMilliseconds(0L),
+ end = Instant.fromEpochMilliseconds(3_600_000L),
+ isAllDay = false,
+ zoneId = "UTC",
+ calendarName = calendarName,
+ )
+
+ @Test
+ fun `a file whose events all name one calendar names it`() {
+ assertThat(fileCalendarName(List(3) { event("Birthdays") })).isEqualTo("Birthdays")
+ }
+
+ @Test
+ fun `disagreeing events name none`() {
+ assertThat(fileCalendarName(listOf(event("Birthdays"), event("Anniversaries")))).isNull()
+ }
+
+ @Test
+ fun `one tagged event among unnamed ones names none`() {
+ // CATEGORIES is a tag list everywhere but the Fossify family, so a lone
+ // tagged event must not decide where the whole file lands.
+ assertThat(fileCalendarName(listOf(event("Holiday")) + List(9) { event(null) })).isNull()
+ }
+
+ @Test
+ fun `a file that names nothing names none`() {
+ assertThat(fileCalendarName(List(3) { event(null) })).isNull()
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipSeatTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipSeatTest.kt
new file mode 100644
index 0000000..710b2ad
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipSeatTest.kt
@@ -0,0 +1,120 @@
+package de.jeanlucmakiola.calendula.ui.month
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.atTime
+import kotlinx.datetime.plus
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toInstant
+import org.junit.jupiter.api.Test
+
+/**
+ * Where a week row draws a moved chip — what the settling copy glides onto
+ * (#68, #253).
+ */
+class ChipSeatTest {
+
+ private val monday = LocalDate(2026, 6, 8)
+ private val days = (0..6).map { monday.plus(it, DateTimeUnit.DAY) }
+
+ private fun event(id: Long, title: String = "E") = EventInstance(
+ instanceId = id,
+ eventId = id,
+ calendarId = 1L,
+ title = title,
+ start = monday.atTime(9, 0).toInstant(TimeZone.UTC),
+ end = monday.atTime(10, 0).toInstant(TimeZone.UTC),
+ isAllDay = false,
+ color = 0xFF112233.toInt(),
+ location = null,
+ )
+
+ /**
+ * A grid holding [seated] in lane 0 as one bar across [cols], and nothing
+ * else — the seating a [MonthWeek] gives a span.
+ */
+ private fun row(seated: EventInstance, cols: IntRange) = MonthWeek(
+ days = days,
+ spans = listOf(
+ MonthSpan(
+ event = seated,
+ startCol = cols.first,
+ endCol = cols.last,
+ lane = 0,
+ continuesLeft = false,
+ continuesRight = false,
+ ),
+ ),
+ timedByDay = emptyMap(),
+ countByDay = emptyMap(),
+ )
+
+ /** A grid holding one single-day pill per day of [cols], all of [seated]. */
+ private fun pills(seated: EventInstance, cols: IntRange) = MonthWeek(
+ days = days,
+ spans = emptyList(),
+ timedByDay = cols.associate { days[it] to listOf(seated) },
+ countByDay = emptyMap(),
+ )
+
+ private fun seat(week: MonthWeek, event: EventInstance, date: LocalDate) = chipSeat(
+ days = days,
+ laneCount = 3,
+ chipAt = { col, lane -> week.chipAt(col, lane, 3) },
+ chipStart = { col, lane -> week.chipStartCol(col, lane) },
+ event = event,
+ date = date,
+ )
+
+ @Test
+ fun `a single-day chip is seated in its own column`() {
+ val ev = event(1L)
+ assertThat(seat(row(ev, 3..3), ev, days[3])).isEqualTo(3 to 0)
+ }
+
+ @Test
+ fun `a bar is seated from its first column, not the day it was dropped on`() {
+ val ev = event(1L)
+ // Covers Wed–Thu; dropped on the Thursday it now runs into.
+ assertThat(seat(row(ev, 2..3), ev, days[3])).isEqualTo(2 to 0)
+ }
+
+ @Test
+ fun `a bar carried in from the previous week is seated at column zero`() {
+ val ev = event(1L)
+ assertThat(seat(row(ev, 0..4), ev, days[4])).isEqualTo(0 to 0)
+ }
+
+ @Test
+ fun `a row that does not hold the event seats nothing`() {
+ val ev = event(1L)
+ assertThat(seat(row(ev, 2..3), ev, days[5])).isNull()
+ assertThat(seat(row(ev, 2..3), ev, LocalDate(2026, 7, 1))).isNull()
+ }
+
+ @Test
+ fun `a re-read chip is matched by title once its event id has changed`() {
+ // A single-occurrence move writes a new event id; the title survives.
+ val moved = event(1L, title = "Standup")
+ val reRead = event(9L, title = "Standup")
+ assertThat(seat(row(reRead, 3..3), moved, days[3])).isEqualTo(3 to 0)
+ }
+
+ @Test
+ fun `two untitled events are not the same event`() {
+ val moved = event(1L, title = "")
+ val other = event(2L, title = "")
+ assertThat(isSameEvent(other, moved)).isFalse()
+ assertThat(seat(row(other, 3..3), moved, days[3])).isNull()
+ }
+
+ @Test
+ fun `a daily series seats each occurrence in its own column`() {
+ // Every column holds an occurrence of the same event id, so a walk left
+ // by identity would run to the start of the row (#253).
+ val ev = event(1L, title = "Standup")
+ assertThat(seat(pills(ev, 0..6), ev, days[4])).isEqualTo(4 to 0)
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt
index 52e43f8..e4fc21d 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt
@@ -44,6 +44,19 @@ class MonthLayoutTest {
location = null,
)
+ /** A timed event running from [date] 20:00 into the next day at [endHour]. */
+ private fun overnight(date: LocalDate, endHour: Int = 8) = EventInstance(
+ instanceId = 7L,
+ eventId = 7L,
+ calendarId = 1L,
+ title = "Night shift",
+ start = at(date, 20),
+ end = at(date.plus(1, DateTimeUnit.DAY), endHour),
+ isAllDay = false,
+ color = 0xFF112233.toInt(),
+ location = null,
+ )
+
/** All-day events live at UTC midnights with an *exclusive* end. */
private fun allDay(
from: LocalDate,
@@ -162,6 +175,43 @@ class MonthLayoutTest {
assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty()
}
+ @Test
+ fun `an event crossing midnight is one bar, not two pills, on both sides of a week seam`() {
+ // Sun 14 Jun 20:00 to Mon 15 Jun 08:00 — the seam of a Monday-anchored
+ // grid: one day in this row, one in the next. Counting per row made each
+ // a standalone pill with nothing saying they were one event (#253).
+ val overnight = overnight(LocalDate(2026, 6, 14))
+ val head = layoutCalendarWeek(weekOf8th, listOf(overnight), zone)
+ assertThat(head.timedByDay.values.flatten()).isEmpty()
+ val headSpan = head.spans.single()
+ assertThat(headSpan.startCol).isEqualTo(6)
+ assertThat(headSpan.endCol).isEqualTo(6)
+ assertThat(headSpan.continuesLeft).isFalse()
+ assertThat(headSpan.continuesRight).isTrue()
+
+ val tailRow = layoutCalendarWeek(
+ weekOf8th.map { it.plus(7, DateTimeUnit.DAY) },
+ listOf(overnight),
+ zone,
+ )
+ assertThat(tailRow.timedByDay.values.flatten()).isEmpty()
+ val tailSpan = tailRow.spans.single()
+ assertThat(tailSpan.startCol).isEqualTo(0)
+ assertThat(tailSpan.endCol).isEqualTo(0)
+ assertThat(tailSpan.continuesLeft).isTrue()
+ assertThat(tailSpan.continuesRight).isFalse()
+ }
+
+ @Test
+ fun `an event ending exactly at midnight stays a single-day pill`() {
+ val untilMidnight = overnight(LocalDate(2026, 6, 10), endHour = 0)
+ val week = layoutCalendarWeek(weekOf8th, listOf(untilMidnight), zone)
+
+ assertThat(week.spans).isEmpty()
+ assertThat(week.timedByDay[LocalDate(2026, 6, 10)]).hasSize(1)
+ assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty()
+ }
+
@Test
fun `countByDay totals bars and pills on each date`() {
val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L)
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt
new file mode 100644
index 0000000..8242073
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt
@@ -0,0 +1,189 @@
+package de.jeanlucmakiola.calendula.widget
+
+import android.content.Intent
+import com.google.common.truth.Truth.assertThat
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.atStartOfDayIn
+import kotlinx.datetime.toInstant
+import kotlinx.datetime.toLocalDateTime
+import org.junit.jupiter.api.Test
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Instant
+
+/**
+ * The rollover alarm is what makes the widgets stop highlighting yesterday
+ * (#228), so the "when is the next local midnight" arithmetic is the one piece
+ * worth pinning down — especially where midnight is not 00:00.
+ */
+class WidgetRolloverSchedulerTest {
+
+ private val berlin = TimeZone.of("Europe/Berlin")
+
+ private fun at(local: String, zone: TimeZone): Instant =
+ LocalDateTime.parse(local).toInstant(zone)
+
+ private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
+ WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
+
+ // --- the ordinary day ----------------------------------------------------
+
+ @Test
+ fun `midday rolls over at the coming midnight`() {
+ val next = nextRollover("2026-08-27T12:00:00")
+ assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
+ }
+
+ @Test
+ fun `a second before midnight still targets tonight, not tomorrow night`() {
+ val next = nextRollover("2026-08-27T23:59:59")
+ assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
+ }
+
+ @Test
+ fun `at midnight exactly the target is the next day, never the current instant`() {
+ // Re-arming after a firing must move a whole day on, or the widget wakes
+ // itself in a tight loop.
+ val now = at("2026-08-28T00:00:00", berlin)
+ val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
+ assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
+ assertThat(next - now).isGreaterThan(Duration.ZERO)
+ }
+
+ @Test
+ fun `re-arming from the slack instant itself moves a full day on`() {
+ // What actually happens in practice: the receiver runs at midnight + slack.
+ val now = at("2026-08-28T00:00:05", berlin)
+ assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
+ .isEqualTo(at("2026-08-29T00:00:05", berlin))
+ }
+
+ @Test
+ fun `the result is always in the future for every minute of a day`() {
+ val zone = berlin
+ // Spans Berlin's 2024 spring-forward: the likeliest source of a target
+ // in the past, i.e. an alarm that fires immediately, forever.
+ var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
+ val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
+ while (probe < end) {
+ assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
+ probe += 1.minutes
+ }
+ }
+
+ // --- daylight saving -----------------------------------------------------
+
+ @Test
+ fun `spring forward keeps the rollover one day away, not one hour short`() {
+ // Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
+ // A rollover computed as "now + 24h" would land at 01:00 on 1 April.
+ val now = at("2024-03-30T12:00:00", berlin)
+ val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
+ assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
+ assertThat(next - now).isLessThan(24.hours)
+ }
+
+ @Test
+ fun `fall back does not overshoot into the repeated hour`() {
+ // Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
+ // "now + 24h" would land at 23:00 on the 26th and never roll over.
+ val now = at("2024-10-26T12:00:00", berlin)
+ val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
+ assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
+ assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
+ }
+
+ @Test
+ fun `a zone that repeats midnight takes the first start of day`() {
+ // Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
+ // began twice. Pinned as the accepted trade: the widget runs an hour
+ // ahead until the next redraw. No live zone does this since 2019.
+ val saoPaulo = TimeZone.of("America/Sao_Paulo")
+ val next = WidgetRolloverScheduler.nextRolloverAt(
+ at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
+ )
+ val local = next.toLocalDateTime(saoPaulo)
+ assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
+ assertThat(local.hour).isEqualTo(0)
+ }
+
+ @Test
+ fun `a zone where midnight does not exist rolls over at the real start of day`() {
+ // Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
+ // Targeting a literal 00:00 there would arm an instant on the wrong day.
+ val havana = TimeZone.of("America/Havana")
+ val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
+ val local = next.toLocalDateTime(havana)
+ assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
+ assertThat(local.hour).isEqualTo(1)
+ assertThat(local.minute).isEqualTo(0)
+ // And it is genuinely the first instant of that date, not a guess.
+ assertThat(next).isEqualTo(
+ LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
+ WidgetRolloverScheduler.ROLLOVER_SLACK,
+ )
+ }
+
+ // --- timezone changes ----------------------------------------------------
+
+ @Test
+ fun `the same instant rolls over at different times in different zones`() {
+ // TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
+ // follows the zone, not a cached offset.
+ val instant = at("2026-08-27T12:00:00", berlin)
+ val tokyo = TimeZone.of("Asia/Tokyo")
+ val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
+ val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
+ assertThat(tokyoNext).isNotEqualTo(berlinNext)
+ assertThat(tokyoNext).isLessThan(berlinNext)
+ assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
+ }
+
+ @Test
+ fun `a half-hour offset zone still lands on its own midnight`() {
+ val kathmandu = TimeZone.of("Asia/Kathmandu")
+ val next = WidgetRolloverScheduler.nextRolloverAt(
+ at("2026-08-27T12:00:00", kathmandu), kathmandu,
+ )
+ val local = next.toLocalDateTime(kathmandu)
+ assertThat(local.date.toString()).isEqualTo("2026-08-28")
+ assertThat(local.hour).isEqualTo(0)
+ }
+
+ // --- the wiring ----------------------------------------------------------
+
+ @Test
+ fun `the receiver actually handles the action the alarm is sent with`() {
+ // The single point where the whole fix would die silently: the alarm
+ // fires, the receiver drops it on the action guard, nothing redraws.
+ assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
+ .contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
+ }
+
+ @Test
+ fun `a data change is handled, and re-arms only on the throttle`() {
+ // PROVIDER_CHANGED fires on every calendar write — each event of a bulk
+ // import, each drop of a drag — so it is not an unconditional re-arm.
+ // It is not dropped either: the alarm is inexact and the system may lose
+ // it, and this is the only signal that keeps arriving to notice.
+ assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
+ .containsAtLeast(Intent.ACTION_PROVIDER_CHANGED, Intent.ACTION_DATE_CHANGED)
+ assertThat(WidgetUpdateReceiver.REARM_ACTIONS)
+ .doesNotContain(Intent.ACTION_PROVIDER_CHANGED)
+ }
+
+ @Test
+ fun `everything that loses the alarm re-arms it`() {
+ assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast(
+ WidgetUpdateReceiver.ACTION_ROLLOVER,
+ Intent.ACTION_BOOT_COMPLETED,
+ Intent.ACTION_MY_PACKAGE_REPLACED,
+ Intent.ACTION_TIME_CHANGED,
+ Intent.ACTION_TIMEZONE_CHANGED,
+ Intent.ACTION_DATE_CHANGED,
+ )
+ }
+}
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/app/src/test/resources/ics/fossify-holidays-at.ics b/app/src/test/resources/ics/fossify-holidays-at.ics
new file mode 100644
index 0000000..310ece3
--- /dev/null
+++ b/app/src/test/resources/ics/fossify-holidays-at.ics
@@ -0,0 +1,25 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:Fossify Calendar Holiday Generator
+METHOD:PUBLISH
+X-PUBLISHED-TTL:PT1H
+BEGIN:VEVENT
+UID:fossify_c10f9ad245dc2e57fb3652abee6ed8a06744f0bd
+SUMMARY:Neujahr
+DTSTAMP:20260526T132315Z
+DTSTART;VALUE=DATE:19800101
+DTEND;VALUE=DATE:19800102
+STATUS:CONFIRMED
+RRULE:FREQ=YEARLY
+END:VEVENT
+BEGIN:VEVENT
+UID:fossify_8a60ccf65b65265f768dd33a0b7aefc7620e3ee2
+SUMMARY:Heilige Drei Könige
+DTSTAMP:20260526T132315Z
+DTSTART;VALUE=DATE:19800106
+DTEND;VALUE=DATE:19800107
+STATUS:CONFIRMED
+RRULE:FREQ=YEARLY
+END:VEVENT
+END:VCALENDAR
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index d013789..780f683 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -346,6 +346,101 @@ a latch that disables its own scheduling once a real broadcast arrives — canno
be copied, because our failure mode includes a broadcast that arrives with no
alert row behind it.
+## Importing foreign `.ics`
+
+`IcsParser` is deliberately liberal, because the files it is handed were written
+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.** 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`.
+
+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. 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
+read. That parser works off a fixed table of part names and throws on anything
+outside it, so `sanitizeRrule` is a **whitelist**: unrecognised parts (RFC 7529
+`RSCALE`, vendor `X-` extensions) and unusable values go, and only a missing
+`FREQ` gives up on the rule. It reports whether it actually *dropped* something,
+separately from the rule text it returns — case and a trailing `;` normalise away
+and must not raise the "faulty repeat rule" warning at the user. Behind all that,
+`CalendarRepositoryImpl.importEvents` isolates each event, counting rejects into
+`IcsImportSummary.failed` rather than unwinding the batch and reporting only
+"couldn't read this file".
+
+Smaller dialect handling: `VTODO` components import as events (Calendula models
+no tasks; dropping them silently lost half of some exports), a single-valued
+`CATEGORIES` stands in for the `X-WR-CALNAME` Fossify never writes and preselects
+a target calendar of that name — as a *fallback* only, since everywhere else the
+property is a tag list and must not outrank something that names the calendar
+outright — bare `EXDATE` day codes on a timed series are resolved against the
+series' own time of day (as is a floating `EXDATE` DATE-TIME — RFC 5545 ties it
+to `DTSTART`'s zone, not the device's) while an all-day `EXDATE` keeps the day it
+spells out rather than the day its resolved instant lands on in UTC, and an
+all-day `VALARM` trigger pointing *after* the start — which is how that family
+encodes "on the day at 09:00" — is read as zero days before. On a *timed* event
+the same shape means a follow-up alarm and is dropped: clamping it to zero would
+invent a reminder at the start that the file never asked for.
+
+All-day reminder offsets are **whole days, rounded up** in both directions. A
+file's raw offset is `days × 1440 − timeOfDay`, so rounding to the nearest day
+would drop a day for every producer whose all-day notifications fire after noon
+("1 day before at 18:00" arrives as `TRIGGER:-PT6H`). The export side is the
+mirror image: an all-day row's raw provider `MINUTES` has the firing time encoded
+into it and is normally *negative*, which the writer used to discard as a trigger
+after the event — so `toIcsEvent` decodes it back through
+`fromProviderAllDayMinutes` first, and the importing device re-encodes it against
+its own setting.
+
+**A backup carries the series' deleted occurrences.** `EXDATE` lives on the
+master row, so the export query's `ORIGINAL_ID IS NULL` filter — which does drop
+`RECURRENCE-ID` overrides — never hid it; it simply wasn't read. It is now part
+of `EventExportProjection`, and `IcsWriter` marks an all-day series' exclusions
+`VALUE=DATE`, because 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
+`exportExDates` drops the time part on the way out. Without this, an in-app
+backup→restore resurrected every occurrence the user had deleted.
+
+That re-encode is sampled where the reminder will actually *fire*, not at
+`DTSTART` (`importedAllDayReminderDate`). An imported series' `DTSTART` is only an
+anchor and is routinely ancient — Fossify writes a year-less contact birthday at
+1970, and a real birth year is usually older still — from a year whose timezone
+rules predate DST across most of Europe. Sampling the UTC offset there skews every
+modern occurrence by the delta, the same trap `nextYearlyOccurrence` exists for on
+the managed special-dates path. Decoding is unaffected and stays anchored at
+`DTSTART`: it only asks which local *day* the encoded instant falls on.
+
+Colour arrives as a raw ARGB from an app with no idea which account it is landing
+in. A calendar whose account publishes a palette rejects a raw `EVENT_COLOR`, so
+`buildImportedEventValues` snaps the imported colour to the nearest published key
+(`nearestTo`, measured in Oklab) and writes the raw value only where there is no
+palette. The match runs against `publishedEventColors`, **not** the curated
+`eventColorPalette`: curation is a picker concession that folds look-alikes and
+drops the neutrals outright from an oversized palette, but every published key is
+one the calendar accepts. The single-event review form deliberately keeps no colour at all: its
+first question is which calendar to use, and answering it clears the colour
+anyway.
+
## Testing
JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
diff --git a/fastlane/metadata/android/en-US/changelogs/21904.txt b/fastlane/metadata/android/en-US/changelogs/21904.txt
new file mode 100644
index 0000000..a88d0e8
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/21904.txt
@@ -0,0 +1,10 @@
+Fixed
+• An occurrence you deleted from a repeating event stays deleted — through a re-timing, a backup and a restore.
+• "Only this event" now saves your edit on calendars that have never synced, and deleting one from an event that doesn't repeat deletes the event.
+• A reminder left on your account's default no longer goes off as the event starts.
+• The widgets turn the page at midnight instead of leaving yesterday as "today".
+• An event running past midnight can be dragged by either half.
+• One event the calendar refuses no longer costs you the whole import, and an import cut short tells you how far it got.
+
+Added
+• Belarusian, Hungarian and Slovak, from community translators on Weblate.