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.
This commit is contained in:
2026-09-02 12:09:54 +02:00
parent ee23a91acb
commit 73ef73d846
33 changed files with 1279 additions and 147 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String, Any?> {
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<String, Any?> = 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<String> = 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;<stamps>`), and so does DAVx5; the
* iCalendar `TZID=<zone>;` 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

View File

@@ -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, `<zone>;<stamps>`,
* 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<String> = 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<String> {
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()
}

View File

@@ -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<Long, List<Int>>.withoutProviderDefaults(): Map<Long, List<Int>> =
mapValues { (_, minutes) -> minutes.filter { it != MINUTES_DEFAULT } }

View File

@@ -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(),
)

View File

@@ -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<ParsedIcsEvent>()
val warnings = mutableSetOf<IcsParseWarning>()
// 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

View File

@@ -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<ReminderEventInstance>,
@@ -70,17 +76,19 @@ fun planReminders(
zone: ZoneId,
allDayTimeMinutes: Int,
): List<PlannedReminder> = 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. */

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<TimelineDragPiece>,
/**
* 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<LocalDate, Int> get() = date to startMin
@@ -117,9 +123,9 @@ internal fun dragSlices(
): List<DragSlice> {
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(),
)
}

View File

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

View File

@@ -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(),

View File

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

View File

@@ -231,6 +231,8 @@
<string name="event_detail_self_response">Your response: %1$s</string>
<string name="reminder_at_time">At time of event</string>
<string name="reminder_default">Default reminder</string>
<!-- A reminder that fires after the event starts, e.g. "2 hours after". -->
<string name="reminder_after">%1$s after</string>
<plurals name="reminder_minutes">
<item quantity="one">%d minute before</item>
<item quantity="other">%d minutes before</item>
@@ -695,6 +697,7 @@
<string name="import_done_skipped_label">Duplicates</string>
<string name="import_done_failed_label">Not added</string>
<string name="import_done_failed_note">Some events couldn\'t be added and were left out.</string>
<string name="import_done_stopped_title">Import stopped</string>
<string name="import_close">Close</string>
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
<string name="import_warning_no_start">An event without a start time was skipped.</string>
@@ -715,6 +718,10 @@
<item quantity="one">Import %d event</item>
<item quantity="other">Import %d events</item>
</plurals>
<plurals name="import_done_stopped">
<item quantity="one">The calendar stopped accepting events part way through. %d event from the file was never added.</item>
<item quantity="other">The calendar stopped accepting events part way through. %d events from the file were never added.</item>
</plurals>
<plurals name="import_done_imported">
<item quantity="one">Imported %d event.</item>
<item quantity="other">Imported %d events.</item>

View File

@@ -10,6 +10,7 @@
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="ar" />
<locale android:name="be" />
<locale android:name="cs" />
<locale android:name="de" />
<locale android:name="es" />
@@ -19,5 +20,6 @@
<locale android:name="pl" />
<locale android:name="pt-BR" />
<locale android:name="ru" />
<locale android:name="sk" />
<locale android:name="zh-CN" />
</locale-config>

View File

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

View File

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

View File

@@ -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;<stamps>`, 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:0010: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=<zone>;<floating stamps>. 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,

View File

@@ -100,6 +100,10 @@ internal class FakeCalendarDataSource : CalendarDataSource {
/** Thrown instead of [writeError] for events whose summary is in this set. */
val failingImportSummaries = mutableSetOf<String>()
/** 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<EventColorOption> = emptyList()
@@ -110,7 +114,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
colorPalette: List<EventColorOption>,
): 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 {

View File

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

View File

@@ -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<Int>())
}
@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))
}
}

View File

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

View File

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

View File

@@ -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()
}
}

View File

@@ -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,
)
}
}

View File

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