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.