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

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