From 73ef73d8461d390129c1942660bd08e3c0099ff3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 2 Sep 2026 12:09:54 +0200 Subject: [PATCH] Prepare v2.19.4 Version bump, release notes, Belarusian and Slovak locales, and the rest of the midnight-drag work (#253). Also the fixes from reviewing this branch: EXDATE zone prefixes in the form the provider actually writes, "only this event" on a row that does not recur, RDATE carried through the recurrence rewrite, an exact test for the reminder default sentinel, the widget's re-arm backstop, and the counts a stopped import reports. --- CHANGELOG.md | 35 +++ app/build.gradle.kts | 4 +- .../data/calendar/AllDayReminderEncoding.kt | 26 +- .../data/calendar/CalendarDataSource.kt | 58 +++- .../data/calendar/CalendarRepository.kt | 10 +- .../data/calendar/CalendarRepositoryImpl.kt | 26 +- .../data/calendar/EventWriteMapper.kt | 243 ++++++++++++++--- .../data/calendar/IcsExportMapper.kt | 49 +++- .../data/calendar/ProviderReminderMinutes.kt | 24 ++ .../data/reminders/ReminderScanner.kt | 4 +- .../calendula/domain/ics/IcsParser.kt | 20 +- .../domain/reminders/ReminderPlan.kt | 30 ++- .../calendula/ui/CalendarHost.kt | 2 +- .../calendula/ui/common/EventMoveScope.kt | 8 +- .../calendula/ui/common/ReminderFormatting.kt | 16 +- .../ui/common/RescheduleViewModel.kt | 14 + .../calendula/ui/common/TimelineDrag.kt | 100 +++++-- .../calendula/ui/edit/EventEditViewModel.kt | 7 +- .../calendula/ui/imports/ImportScreen.kt | 40 ++- .../calendula/widget/WidgetUpdateReceiver.kt | 67 ++++- app/src/main/res/values/strings.xml | 7 + app/src/main/res/xml/locales_config.xml | 2 + .../calendar/AllDayReminderEncodingTest.kt | 29 ++ .../calendar/CalendarRepositoryImplTest.kt | 59 +++- .../data/calendar/EventWriteMapperTest.kt | 254 +++++++++++++++++- .../data/calendar/FakeCalendarDataSource.kt | 7 +- .../data/calendar/IcsExportMapperTest.kt | 126 +++++++++ .../calendar/ProviderReminderMinutesTest.kt | 33 +++ .../domain/ics/IcsFossifyImportTest.kt | 21 ++ .../ui/common/RescheduleViewModelTest.kt | 44 +++ .../ui/common/TimedBlockShapeTest.kt | 26 ++ .../widget/WidgetRolloverSchedulerTest.kt | 25 ++ .../android/en-US/changelogs/21904.txt | 10 + 33 files changed, 1279 insertions(+), 147 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ProviderReminderMinutes.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ProviderReminderMinutesTest.kt create mode 100644 fastlane/metadata/android/en-US/changelogs/21904.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index ae31639..22c357a 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 @@ -56,6 +58,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 @@ -64,6 +85,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 and Slovak**, both contributed as community translations through + [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/). + Belarusian covers about two thirds of the app and Slovak about a third, so + untranslated parts still show in English until they fill out. Pick either under + Settings → Language or in Android's per-app language settings. Thanks to Dmitri + Gabinski (Belarusian) 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, and still have room. Thanks to Sup99999 + (Arabic), Bazyli Cyran (Polish), JeanWenzel (Simplified Chinese), James + Trivette (Czech) and SpiritOTHawk (Russian). ## [2.19.3] — 2026-08-22 @@ -1533,3 +1567,4 @@ automatically, with zero telemetry and no internet permission. [#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/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt index 6aa6e1d..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 } /** @@ -103,10 +108,19 @@ internal fun importedAllDayReminderDate( * [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 eab8af6..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 @@ -261,11 +261,13 @@ interface CalendarDataSource { * 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]). - * [allDayReminderTimeMinutes]: see [insertEvent]. + * 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 @@ -878,7 +880,17 @@ class AndroidCalendarDataSource @Inject constructor( 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") } } @@ -1222,14 +1234,20 @@ class AndroidCalendarDataSource @Inject constructor( override fun updateOccurrence( eventId: Long, beginMillis: Long, + original: EventForm, form: EventForm, allDayReminderTimeMinutes: Int, ): Long { val row = querySeriesRow(eventId) - // Stricter than deleteOccurrence's bare _sync_id check: EXDATE only means - // something on a row that recurs, so a non-recurring one keeps the - // exception path rather than getting a recurrence set written onto it. - if (row.syncId == null && !row.rrule.isNullOrBlank()) { + // 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. @@ -1285,7 +1303,7 @@ class AndroidCalendarDataSource @Inject constructor( // 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, isAllDay = row.allDay != 0)) { + 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 @@ -1297,6 +1315,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, @@ -1381,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(), @@ -1461,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 -> @@ -1473,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 @@ -1488,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, @@ -1679,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 @@ -1692,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 0af9826..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 @@ -228,7 +228,8 @@ class CalendarRepositoryImpl @Inject constructor( var imported = 0 var skipped = 0 var failed = 0 - for (event in events) { + 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) { @@ -240,6 +241,17 @@ class CalendarRepositoryImpl @Inject constructor( 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 @@ -248,7 +260,12 @@ class CalendarRepositoryImpl @Inject constructor( Log.w(TAG, "Skipped an unimportable event", e) } } - IcsImportSummary(imported = imported, skippedDuplicate = skipped, failed = failed) + IcsImportSummary( + imported = imported, + skippedDuplicate = skipped, + failed = failed, + notAttempted = notAttempted, + ) } override suspend fun createEvent(form: EventForm): Long = withContext(io) { @@ -281,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( 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 12f01c0..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 @@ -14,10 +14,12 @@ 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( @@ -443,29 +445,69 @@ 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(",") } /** @@ -480,9 +522,15 @@ internal fun exdateContains( existingExdate: String?, occurrenceMillis: Long, isAllDay: Boolean, + timezone: String?, ): Boolean { val stamp = formatExdateStamp(occurrenceMillis, isAllDay) - return existingExdate?.split(',')?.any { it.trim() == stamp } == true + 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 } } /** @@ -499,10 +547,11 @@ internal fun exdateContains( * [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?, @@ -514,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() @@ -534,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 @@ -581,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 = @@ -602,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 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 90a1a3a..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 @@ -59,7 +59,11 @@ internal fun ColumnReader.toIcsEvent( exDates = if (rrule == null) { emptyList() } else { - exportExDates(getString(EventExportProjection.IDX_EXDATE), isAllDay) + exportExDates( + getString(EventExportProjection.IDX_EXDATE), + isAllDay, + getString(EventExportProjection.IDX_EVENT_TIMEZONE), + ) }, location = getString(EventExportProjection.IDX_LOCATION), description = getString(EventExportProjection.IDX_DESCRIPTION), @@ -85,11 +89,40 @@ internal fun ColumnReader.toIcsEvent( * 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): List = exdate - ?.split(',') - ?.map { it.trim() } - ?.filter { it.isNotEmpty() } - ?.map { if (isAllDay) it.substringBefore('T') else it } - ?.distinct() - .orEmpty() +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/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/ics/IcsParser.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt index c49c5ff..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 @@ -110,13 +110,25 @@ data class IcsParseResult( * 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 * JVM-testable. Liberal-in/strict-out: unknown properties are ignored, a single @@ -134,11 +146,13 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault val lines = unfoldLines(text) val events = mutableListOf() val warnings = mutableSetOf() - // Scanned up front rather than in document order: an X-WR-CALNAME after - // the first VEVENT still names the calendar every event came from. + // 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 == "X-WR-CALNAME" } + .firstOrNull { it.name == CALENDAR_NAME_PROPERTY } ?.let { unescapeText(it.value).trim().ifEmpty { null } } var i = 0 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 0dd735d..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,7 +319,7 @@ fun CalendarHost( EventMoveScope( movableCalendarIds = movableCalendarIds, dragEnabled = dragToReschedule, - move = reschedule::move, + move = { reschedule.move(it) }, inFlight = reschedule.inFlight, undoStarted = reschedule.undoStarted, abandoned = reschedule.abandoned, 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 048d663..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 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 d243a9d..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 @@ -202,6 +202,10 @@ class RescheduleViewModel @Inject constructor( * Take a drop, unless one is already being written. False means nothing will * 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) { @@ -254,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 @@ -272,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 @@ -368,6 +381,7 @@ class RescheduleViewModel @Inject constructor( repository.updateOccurrence( request.eventId, request.beginMillis, + prepared.original, prepared.updated, ) 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 7760133..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 @@ -83,6 +83,12 @@ data class TimelineDrag( 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 @@ -117,9 +123,9 @@ internal fun dragSlices( ): List { val span = spanMin.coerceAtLeast(0) val end = eventStartMin + span - val first = eventStartMin.floorDiv(MINUTES_PER_DAY) - // A zero-length event still has a block, on the one day it sits in. - val last = maxOf((end - 1).floorDiv(MINUTES_PER_DAY), first) + 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() @@ -137,6 +143,39 @@ internal fun dragSlices( } } +/** + * 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 @@ -366,7 +405,11 @@ class TimelineDragController { 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 = landed.event.eventId to landed.event.start settledAbandonedMark = abandonedTicks?.value ?: 0 @@ -413,6 +456,11 @@ class TimelineDragController { 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 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], @@ -421,29 +469,27 @@ class TimelineDragController { 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 = dragSlices( - eventStartMin, - eventSpan, - within = -dayIndex..days.lastIndex - dayIndex, - ).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, - ) - }, + 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(), ) } 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 d00d80b..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 @@ -737,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, 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 8e01a6c..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 @@ -219,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, @@ -232,19 +237,31 @@ 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, ) @@ -266,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(), 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 2760edf..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,6 +11,7 @@ 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, and keeps the @@ -24,10 +26,18 @@ import kotlinx.coroutines.launch * - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the * latter is also what arms installs upgrading into the fix. * - * `DATE_CHANGED` is a free extra in the filter that nothing depends on — see - * [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the + * `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. */ @@ -38,22 +48,28 @@ class WidgetUpdateReceiver : BroadcastReceiver() { // for keeps a stray broadcast from costing two wide provider reads. if (intent.action !in HANDLED_ACTIONS) return val appContext = context.applicationContext - // Re-arm first, so the next day boundary is covered whatever the redraw - // does. Every handled action either dropped, consumed or invalidated it. - WidgetRolloverScheduler.sync(appContext) - // The host sends APPWIDGET_UPDATE after both of these anyway, so - // redrawing here would only repeat the work in a cold process, at the + 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. - if (intent.action in REARM_ONLY_ACTIONS) return + 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() } @@ -70,12 +86,35 @@ class WidgetUpdateReceiver : BroadcastReceiver() { Intent.ACTION_MY_PACKAGE_REPLACED, ) - internal val HANDLED_ACTIONS = REARM_ONLY_ACTIONS + setOf( + /** + * 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_PROVIDER_CHANGED, - Intent.ACTION_DATE_CHANGED, 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 474f614..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 @@ -695,6 +697,7 @@ 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. @@ -715,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. diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 18659b8..9d9da83 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -10,6 +10,7 @@ + @@ -19,5 +20,6 @@ + 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 4baaec6..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 @@ -184,6 +184,35 @@ class AllDayReminderEncodingTest { 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 cd8d03e..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)) @@ -822,6 +828,57 @@ class CalendarRepositoryImplTest { 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, 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 96a54c4..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), @@ -988,6 +1213,7 @@ class EventWriteMapperTest { 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, @@ -1091,6 +1317,7 @@ class EventWriteMapperTest { "20260604T080000Z, 20260611T080000Z,20260618T080000Z", 1_781_164_800_000L, isAllDay = false, + timezone = null, ), ).isTrue() } @@ -1099,20 +1326,22 @@ class EventWriteMapperTest { 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), + exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false, timezone = null), ).isTrue() assertThat( - exdateContains("20260611", 1_781_136_000_000L, isAllDay = true), + 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)).isFalse() - assertThat(exdateContains("", 1_781_164_800_000L, isAllDay = false)).isFalse() + 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), + exdateContains("20260610T080000Z", 1_781_164_800_000L, isAllDay = false, timezone = null), ).isFalse() } @@ -1128,6 +1357,7 @@ class EventWriteMapperTest { occurrenceMillis = occurrenceMillis, dtStartMillis = 1_780_560_000_000L, rrule = "FREQ=WEEKLY", + rdate = null, duration = "P5400S", timezone = "Europe/Berlin", allDay = 0, 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 676200b..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 @@ -100,6 +100,10 @@ internal class FakeCalendarDataSource : CalendarDataSource { /** 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() @@ -110,7 +114,7 @@ internal class FakeCalendarDataSource : CalendarDataSource { colorPalette: List, ): Long { lastImportPalette = colorPalette - writeError?.let { throw it } + if (imports++ >= importsBeforeError) writeError?.let { throw it } if (event.summary in failingImportSummaries) error("rejected: ${event.summary}") importedEvents += event to calendarId return nextInsertId @@ -192,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 72f2873..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 @@ -148,6 +148,132 @@ class IcsExportMapperTest { 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( 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/IcsFossifyImportTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt index f7bc50b..d663db6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt @@ -322,6 +322,27 @@ class IcsFossifyImportTest { 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( 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 22a6b3c..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 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 index 5a2a7eb..1e4fc48 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt @@ -180,4 +180,30 @@ class TimedBlockShapeTest { 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/widget/WidgetRolloverSchedulerTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt index da9edee..8242073 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.widget +import android.content.Intent import com.google.common.truth.Truth.assertThat import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime @@ -161,4 +162,28 @@ class WidgetRolloverSchedulerTest { 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/fastlane/metadata/android/en-US/changelogs/21904.txt b/fastlane/metadata/android/en-US/changelogs/21904.txt new file mode 100644 index 0000000..ccc6f4a --- /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 and Slovak, from community translators on Weblate.