Compare commits
6 Commits
feat/edit-
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 34fa9c9c3e | |||
| 53793bfb68 | |||
| 9c7c8cb03a | |||
| 4fea176e28 | |||
| cf9492c7ba | |||
| e6def9e5f7 |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -7,7 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.14.1] — 2026-07-13
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Deleting one occurrence of a repeating event no longer breaks the series.
|
||||||
|
Choosing "This event" when deleting an occurrence of a recurring event could
|
||||||
|
wipe out every *other* occurrence while leaving the one you deleted behind as
|
||||||
|
a stale, still-tappable ghost — and deleting it again brought the series back.
|
||||||
|
A single-occurrence delete now removes exactly that occurrence and leaves the
|
||||||
|
rest of the series untouched, and the deleted occurrence disappears from the
|
||||||
|
grid straight away. This holds on every kind of calendar, including the
|
||||||
|
on-device ones Calendula keeps for contact birthdays and anniversaries, where
|
||||||
|
the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
|
||||||
|
- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
|
||||||
|
Calendula to answer the "new event" hand-off from other apps and widgets; now
|
||||||
|
it also answers the "open this event" one, so tapping an existing event in a
|
||||||
|
widget such as Todo Agenda offers Calendula and lands on that event's details.
|
||||||
|
Thanks to @bushrang3r for the report ([#48]).
|
||||||
|
- Events created from other apps get your default reminder. An event handed over
|
||||||
|
by another app or widget — Google Maps' "add to calendar", the Todo Agenda
|
||||||
|
widget's "+" — opened with no reminder at all, ignoring the default set in
|
||||||
|
Settings. It now starts with your default reminder, the same as an event you
|
||||||
|
create in Calendula. An event opened from an `.ics` file is treated differently,
|
||||||
|
because the file has its own say: Calendula keeps whatever reminders it carries
|
||||||
|
(including none at all) and asks you once whether to apply your default instead
|
||||||
|
— it never quietly overrides the file. If you have no default set, it doesn't
|
||||||
|
ask ([#49]).
|
||||||
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
||||||
app (such as DAVx5), the event colour picker showed every colour the account
|
app (such as DAVx5), the event colour picker showed every colour the account
|
||||||
publishes — nearly 150 swatches in alphabetical order, many of them
|
publishes — nearly 150 swatches in alphabetical order, many of them
|
||||||
@@ -888,3 +913,6 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||||
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
||||||
|
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
||||||
|
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
||||||
|
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21400
|
versionCode = 21401
|
||||||
versionName = "2.14.0"
|
versionName = "2.14.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -979,6 +979,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.DURATION,
|
CalendarContract.Events.DURATION,
|
||||||
CalendarContract.Events.ALL_DAY,
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
CalendarContract.Events._SYNC_ID,
|
||||||
|
CalendarContract.Events.EXDATE,
|
||||||
),
|
),
|
||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
@@ -989,6 +991,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
timezone = c.getString(2),
|
timezone = c.getString(2),
|
||||||
duration = c.getString(3),
|
duration = c.getString(3),
|
||||||
allDay = c.getInt(4),
|
allDay = c.getInt(4),
|
||||||
|
syncId = c.getString(5),
|
||||||
|
exdate = c.getString(6),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -1001,6 +1005,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val timezone: String?,
|
val timezone: String?,
|
||||||
val duration: String?,
|
val duration: String?,
|
||||||
val allDay: Int,
|
val allDay: Int,
|
||||||
|
/** Null on a local calendar (and before a synced event's first push). */
|
||||||
|
val syncId: String? = null,
|
||||||
|
val exdate: String? = null,
|
||||||
) {
|
) {
|
||||||
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||||
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||||
@@ -1191,15 +1198,38 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
// A cancelled exception row hides exactly this occurrence; the sync
|
|
||||||
// adapter turns it into an EXDATE/cancelled VEVENT upstream. It has to
|
|
||||||
// carry the full time set (DTSTART + DURATION + zone), not just STATUS:
|
|
||||||
// the provider only demotes the cloned exception to a single instance —
|
|
||||||
// clearing the inherited RRULE — when it can derive that instance from
|
|
||||||
// those columns. A STATUS-only cancel left the RRULE standing and
|
|
||||||
// cancelled the whole series, wiping every other occurrence (#47), the
|
|
||||||
// same trap the edit path documents (Codeberg #16).
|
|
||||||
val row = querySeriesRow(eventId)
|
val row = querySeriesRow(eventId)
|
||||||
|
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
|
||||||
|
// ORIGINAL_SYNC_ID, so with none the link never forms and the
|
||||||
|
// provider's expansion of the *parent* collapses, taking every other
|
||||||
|
// occurrence with it (#47 on a local calendar). EXDATE needs no link.
|
||||||
|
// Calendula's own contact special-date calendars are local and hold
|
||||||
|
// yearly series, so this path is reached in normal use.
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = row.exdate,
|
||||||
|
occurrenceMillis = beginMillis,
|
||||||
|
dtStartMillis = row.dtStartMillis,
|
||||||
|
rrule = row.rrule,
|
||||||
|
duration = row.duration,
|
||||||
|
timezone = row.timezone,
|
||||||
|
allDay = row.allDay,
|
||||||
|
)
|
||||||
|
val updated = resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
values.toContentValues(), null, null,
|
||||||
|
)
|
||||||
|
if (updated == 0) {
|
||||||
|
throw WriteFailedException("exdate occurrence event id=$eventId begin=$beginMillis")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A cancelled exception row hides exactly this occurrence; the sync
|
||||||
|
// adapter turns it into an EXDATE/cancelled VEVENT upstream. It carries
|
||||||
|
// the full time set (DTSTART + DURATION + zone) so the provider derives a
|
||||||
|
// single instance rather than cloning the master's RRULE — the same trap
|
||||||
|
// the edit path documents (Codeberg #16).
|
||||||
val values = buildOccurrenceCancelValues(
|
val values = buildOccurrenceCancelValues(
|
||||||
originalInstanceMillis = beginMillis,
|
originalInstanceMillis = beginMillis,
|
||||||
dtStartMillis = beginMillis,
|
dtStartMillis = beginMillis,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -36,15 +37,18 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
|
|
||||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
// DTEND is null — derive the length from DURATION (as SearchMapper and
|
||||||
// specific occurrence supply the real per-occurrence times from
|
// IcsExportMapper do). Callers that opened a specific occurrence overwrite
|
||||||
// CalendarContract.Instances. A present-but-backwards DTEND is malformed,
|
// both times with the per-occurrence values from CalendarContract.Instances;
|
||||||
// but dropping the row would make the event un-openable — the same trap as
|
// a caller that names no occurrence (a bare content://.../events/<id> VIEW
|
||||||
// the pre-1970 DTSTART bug above (issue #34): it would surface as the
|
// intent, issue #48) keeps this row's own times, so the length has to be
|
||||||
// generic error screen with no way to open the event and fix it. Clamp to a
|
// right here or the series renders zero-length. A present-but-backwards
|
||||||
// zero-length event instead (matching SearchMapper's coerceAtLeast).
|
// DTEND is malformed, but dropping the row would make the event un-openable
|
||||||
|
// — the same trap as the pre-1970 DTSTART bug above (issue #34): it would
|
||||||
|
// surface as the generic error screen with no way to open the event and fix
|
||||||
|
// it. Clamp to a zero-length event instead (matching SearchMapper).
|
||||||
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||||
begin
|
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
|
||||||
} else {
|
} else {
|
||||||
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,73 @@ internal fun buildOccurrenceCancelValues(
|
|||||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The master-row columns that drop the occurrence at [occurrenceMillis] from a
|
||||||
|
* series by adding it to `EXDATE` — the path for events that have **no
|
||||||
|
* `_sync_id`** (a local calendar, or a synced event not yet pushed).
|
||||||
|
*
|
||||||
|
* A cancelled exception row (see [buildOccurrenceCancelValues]) only attaches to
|
||||||
|
* its parent through `ORIGINAL_SYNC_ID`. Without a `_sync_id` the link never
|
||||||
|
* forms, and the provider's expansion of the *parent* collapses — every other
|
||||||
|
* occurrence disappears (Codeberg #47, reproduced on a local calendar). EXDATE
|
||||||
|
* needs no link, and is the canonical iCalendar way to drop an occurrence, so a
|
||||||
|
* sync adapter carries it upstream unchanged if the calendar later syncs.
|
||||||
|
*
|
||||||
|
* The whole time/recurrence set is rewritten alongside it on purpose. The
|
||||||
|
* provider does **not** treat an EXDATE-only update as a recurrence change: it
|
||||||
|
* leaves the expanded `Instances` rows untouched, so the occurrence stays visible
|
||||||
|
* (and, symmetrically, un-excluding one leaves it hidden). Writing DTSTART with
|
||||||
|
* it forces the re-expansion — but DTSTART *alone* makes the provider recompute
|
||||||
|
* `lastDate` as if the event were a single instance, collapsing the series to its
|
||||||
|
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
|
||||||
|
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
|
||||||
|
*
|
||||||
|
* 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`).
|
||||||
|
*/
|
||||||
|
internal fun buildOccurrenceExdateValues(
|
||||||
|
existingExdate: String?,
|
||||||
|
occurrenceMillis: Long,
|
||||||
|
dtStartMillis: Long,
|
||||||
|
rrule: String?,
|
||||||
|
duration: String?,
|
||||||
|
timezone: String?,
|
||||||
|
allDay: Int,
|
||||||
|
): Map<String, Any?> {
|
||||||
|
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
|
||||||
|
val existing = existingExdate?.split(',')
|
||||||
|
?.map { it.trim() }
|
||||||
|
?.filter { it.isNotEmpty() }
|
||||||
|
.orEmpty()
|
||||||
|
val merged = (existing + 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* reads off the UTC calendar day.
|
||||||
|
*/
|
||||||
|
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
|
||||||
|
val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
|
||||||
|
return if (isAllDay) {
|
||||||
|
"%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
|
||||||
|
} else {
|
||||||
|
"%04d%02d%02dT%02d%02d%02dZ".format(
|
||||||
|
utc.year, utc.monthValue, utc.dayOfMonth,
|
||||||
|
utc.hour, utc.minute, utc.second,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||||
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ internal object EventDetailProjection {
|
|||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
CalendarContract.Events.EVENT_COLOR_KEY,
|
CalendarContract.Events.EVENT_COLOR_KEY,
|
||||||
|
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
||||||
|
// needs it to render a series opened without a named occurrence.
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -104,6 +107,7 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_EVENT_TIMEZONE = 15
|
const val IDX_EVENT_TIMEZONE = 15
|
||||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
const val IDX_EVENT_COLOR_KEY = 17
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
|
const val IDX_DURATION = 18
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class EventDetailMapperTest {
|
|||||||
accessLevel: Any? = null,
|
accessLevel: Any? = null,
|
||||||
timezone: String? = null,
|
timezone: String? = null,
|
||||||
selfStatus: Any? = null,
|
selfStatus: Any? = null,
|
||||||
|
duration: String? = null,
|
||||||
): MapColumnReader = MapColumnReader(
|
): MapColumnReader = MapColumnReader(
|
||||||
EventDetailProjection.IDX_EVENT_ID to eventId,
|
EventDetailProjection.IDX_EVENT_ID to eventId,
|
||||||
EventDetailProjection.IDX_TITLE to title,
|
EventDetailProjection.IDX_TITLE to title,
|
||||||
@@ -51,6 +52,7 @@ class EventDetailMapperTest {
|
|||||||
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
||||||
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
||||||
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
|
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
|
||||||
|
EventDetailProjection.IDX_DURATION to duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun attendeeReader(
|
private fun attendeeReader(
|
||||||
@@ -134,12 +136,32 @@ class EventDetailMapperTest {
|
|||||||
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
|
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
|
||||||
// A yearly birthday/anniversary anchored before the epoch has a
|
// A yearly birthday/anniversary anchored before the epoch has a
|
||||||
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
|
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
|
||||||
// no DTEND (they use DURATION), so it stays end == begin.
|
// no DTEND (they use DURATION), so the length comes from DURATION.
|
||||||
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
|
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
|
||||||
val detail = detailReader(dtstart = begin, dtend = null).toDetail()
|
val detail = detailReader(dtstart = begin, dtend = null, duration = "P1D").toDetail()
|
||||||
assertThat(detail).isNotNull()
|
assertThat(detail).isNotNull()
|
||||||
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
|
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
|
||||||
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin)
|
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin + 86_400_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `absent dtend takes its length from DURATION (issue #48)`() {
|
||||||
|
// A recurring series row has no DTEND. Opened without a named
|
||||||
|
// occurrence — a bare content://…/events/<id> VIEW intent — the row's
|
||||||
|
// own times are what render, so DURATION has to supply the length or
|
||||||
|
// the event shows as zero-length (10:00–10:00).
|
||||||
|
val begin = 1_000_000_000L
|
||||||
|
val detail = detailReader(dtstart = begin, dtend = null, duration = "PT1H").toDetail()
|
||||||
|
assertThat(detail).isNotNull()
|
||||||
|
assertThat(detail!!.instance.end.toEpochMilliseconds()).isEqualTo(begin + 3_600_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `absent dtend and absent DURATION stays zero-length`() {
|
||||||
|
val begin = 1_000_000_000L
|
||||||
|
val detail = detailReader(dtstart = begin, dtend = null, duration = null).toDetail()
|
||||||
|
assertThat(detail).isNotNull()
|
||||||
|
assertThat(detail!!.instance.end.toEpochMilliseconds()).isEqualTo(begin)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -260,6 +260,77 @@ class EventWriteMapperTest {
|
|||||||
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
|
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- buildOccurrenceExdateValues ("delete only this event", no _sync_id) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exdate drop excludes the occurrence and rewrites the recurrence set`() {
|
||||||
|
// 2026-07-15T08:00:00Z.
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = null,
|
||||||
|
occurrenceMillis = 1_784_102_400_000L,
|
||||||
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
|
rrule = "FREQ=DAILY;COUNT=5",
|
||||||
|
duration = "PT1H",
|
||||||
|
timezone = "Europe/Berlin",
|
||||||
|
allDay = 0,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
|
||||||
|
// The whole time/recurrence set rides along: an EXDATE-only update is not
|
||||||
|
// treated as a recurrence change, so the provider would leave the expanded
|
||||||
|
// instances (and the occurrence) in place. DTSTART alone is worse — it
|
||||||
|
// makes the provider recompute lastDate as a single instance and collapse
|
||||||
|
// the series to its first occurrence.
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_783_929_600_000L)
|
||||||
|
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY;COUNT=5")
|
||||||
|
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("PT1H")
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exdate drop appends to an existing exdate list`() {
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = "20260714T080000Z",
|
||||||
|
occurrenceMillis = 1_784_102_400_000L,
|
||||||
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
|
rrule = "FREQ=DAILY;COUNT=5",
|
||||||
|
duration = "PT1H",
|
||||||
|
timezone = "Europe/Berlin",
|
||||||
|
allDay = 0,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE])
|
||||||
|
.isEqualTo("20260714T080000Z,20260715T080000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exdate drop folds away a repeated occurrence`() {
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = "20260715T080000Z",
|
||||||
|
occurrenceMillis = 1_784_102_400_000L,
|
||||||
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
|
rrule = "FREQ=DAILY;COUNT=5",
|
||||||
|
duration = "PT1H",
|
||||||
|
timezone = "Europe/Berlin",
|
||||||
|
allDay = 0,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = null,
|
||||||
|
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
|
||||||
|
dtStartMillis = 1_783_900_800_000L,
|
||||||
|
rrule = "FREQ=YEARLY",
|
||||||
|
duration = "P1D",
|
||||||
|
timezone = "UTC",
|
||||||
|
allDay = 1,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715")
|
||||||
|
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
// --- per-event colour ---
|
// --- per-event colour ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
34
fastlane/metadata/android/en-US/changelogs/21401.txt
Normal file
34
fastlane/metadata/android/en-US/changelogs/21401.txt
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
### Fixed
|
||||||
|
- Deleting one occurrence of a repeating event no longer breaks the series.
|
||||||
|
Choosing "This event" when deleting an occurrence of a recurring event could
|
||||||
|
wipe out every *other* occurrence while leaving the one you deleted behind as
|
||||||
|
a stale, still-tappable ghost — and deleting it again brought the series back.
|
||||||
|
A single-occurrence delete now removes exactly that occurrence and leaves the
|
||||||
|
rest of the series untouched, and the deleted occurrence disappears from the
|
||||||
|
grid straight away. This holds on every kind of calendar, including the
|
||||||
|
on-device ones Calendula keeps for contact birthdays and anniversaries, where
|
||||||
|
the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
|
||||||
|
- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
|
||||||
|
Calendula to answer the "new event" hand-off from other apps and widgets; now
|
||||||
|
it also answers the "open this event" one, so tapping an existing event in a
|
||||||
|
widget such as Todo Agenda offers Calendula and lands on that event's details.
|
||||||
|
Thanks to @bushrang3r for the report ([#48]).
|
||||||
|
- Events created from other apps get your default reminder. An event handed over
|
||||||
|
by another app or widget — Google Maps' "add to calendar", the Todo Agenda
|
||||||
|
widget's "+" — opened with no reminder at all, ignoring the default set in
|
||||||
|
Settings. It now starts with your default reminder, the same as an event you
|
||||||
|
create in Calendula. An event opened from an `.ics` file is treated differently,
|
||||||
|
because the file has its own say: Calendula keeps whatever reminders it carries
|
||||||
|
(including none at all) and asks you once whether to apply your default instead
|
||||||
|
— it never quietly overrides the file. If you have no default set, it doesn't
|
||||||
|
ask ([#49]).
|
||||||
|
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
||||||
|
app (such as DAVx5), the event colour picker showed every colour the account
|
||||||
|
publishes — nearly 150 swatches in alphabetical order, many of them
|
||||||
|
duplicates or near-identical shades. The picker now shows only visually
|
||||||
|
distinct colours, arranged as a rainbow; near-duplicate shades and the
|
||||||
|
washed-out neutrals are folded away so no two swatches look alike. Picked
|
||||||
|
colours still sync exactly as before, and calendars with hand-picked
|
||||||
|
palettes (like Google's) are unaffected. Thanks to @ptab for the report
|
||||||
|
([#22]).
|
||||||
|
|
||||||
Reference in New Issue
Block a user