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:
35
CHANGELOG.md
35
CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.19.4] — 2026-09-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **A deleted occurrence no longer comes back when the series is re-timed.**
|
- **A deleted occurrence no longer comes back when the series is re-timed.**
|
||||||
Delete a single occurrence of a repeating event, then change the time of the
|
Delete a single occurrence of a repeating event, then change the time of the
|
||||||
@@ -56,6 +58,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
occurrence of a repeating event, back up, restore — and it was there again,
|
occurrence of a repeating event, back up, restore — and it was there again,
|
||||||
because the removals never made it into the file. They travel with the series
|
because the removals never made it into the file. They travel with the series
|
||||||
now, so a restored calendar looks the way you left it ([#225]).
|
now, so a restored calendar looks the way you left it ([#225]).
|
||||||
|
- **A reminder left on your account's default no longer went off as the event
|
||||||
|
started.** An event whose reminder says "Default" is the account's to time, not
|
||||||
|
Calendula's — it was reading that setting as a lead time of its own and firing a
|
||||||
|
minute after the event began. It now leaves such a reminder alone, and says so
|
||||||
|
on the event.
|
||||||
|
- **"Only this event" on an event that doesn't repeat now edits or deletes it.**
|
||||||
|
Deleting used to write an exclusion onto a one-off event instead — which left
|
||||||
|
the event there and lost its end time — and editing filed the change away as an
|
||||||
|
exception to nothing, so it vanished.
|
||||||
|
- **An import that the calendar cuts short now tells you how far it got.** Losing
|
||||||
|
the calendar or the permission part way through a big file reported the whole
|
||||||
|
import as failed, so importing again added everything twice.
|
||||||
|
- **An event that runs past midnight can be dragged by either half.** Its second
|
||||||
|
half couldn't be picked up at all, and the first could only be moved later. Both
|
||||||
|
halves now move, and both are previewed while you hold either one, so the event
|
||||||
|
reads as moving rather than growing. The blocks are cut square at midnight
|
||||||
|
instead of rounded off, a month row draws an event crossing into the next week
|
||||||
|
as one bar, and a move across a daylight-saving change no longer lands an hour
|
||||||
|
out ([#253]).
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **More of an imported `.ics` survives the trip**: tasks come across as events
|
- **More of an imported `.ics` survives the trip**: tasks come across as events
|
||||||
@@ -64,6 +85,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
and event colours are carried over — matched to the closest colour your
|
and event colours are carried over — matched to the closest colour your
|
||||||
calendar's account offers, so a migrated calendar still reads at a glance
|
calendar's account offers, so a migrated calendar still reads at a glance
|
||||||
([#225]).
|
([#225]).
|
||||||
|
- **Belarusian and Slovak**, both contributed as community translations through
|
||||||
|
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||||
|
Belarusian covers about two thirds of the app and Slovak about a third, so
|
||||||
|
untranslated parts still show in English until they fill out. Pick either under
|
||||||
|
Settings → Language or in Android's per-app language settings. Thanks to Dmitri
|
||||||
|
Gabinski (Belarusian) and Julie (Slovak); help finishing them is very welcome.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Arabic, Polish and Simplified Chinese are now complete**, all three carried
|
||||||
|
the rest of the way this cycle — Simplified Chinese from a fifth of the app to
|
||||||
|
all of it. Czech and Russian grew too, and still have room. Thanks to Sup99999
|
||||||
|
(Arabic), Bazyli Cyran (Polish), JeanWenzel (Simplified Chinese), James
|
||||||
|
Trivette (Czech) and SpiritOTHawk (Russian).
|
||||||
|
|
||||||
## [2.19.3] — 2026-08-22
|
## [2.19.3] — 2026-08-22
|
||||||
|
|
||||||
@@ -1533,3 +1567,4 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
||||||
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
||||||
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
|
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
|
||||||
|
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
|
||||||
|
|||||||
@@ -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 = 21903
|
versionCode = 21904
|
||||||
versionName = "2.19.3"
|
versionName = "2.19.4"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* 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
|
* [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
|
* **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.
|
* the provider fires *after* DTSTART; this is valid and must not be clamped. The
|
||||||
* A negative [semanticMinutes] is the "provider default" sentinel and passes
|
* one negative it never is is [MINUTES_DEFAULT]: the sentinel and the encoding
|
||||||
* through unchanged.
|
* 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(
|
internal fun toProviderAllDayMinutes(
|
||||||
semanticMinutes: Int,
|
semanticMinutes: Int,
|
||||||
@@ -41,12 +45,13 @@ internal fun toProviderAllDayMinutes(
|
|||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
timeOfDayMinutes: Int,
|
timeOfDayMinutes: Int,
|
||||||
): Int {
|
): Int {
|
||||||
if (semanticMinutes < 0) return semanticMinutes
|
if (semanticMinutes == MINUTES_DEFAULT) return semanticMinutes
|
||||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
|
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
|
||||||
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
|
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
|
||||||
.atZone(zone).toInstant().toEpochMilli()
|
.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
|
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
|
||||||
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
|
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
|
||||||
* the reminder actually fires on.
|
* 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(
|
internal fun fromProviderAllDayMinutes(
|
||||||
rawMinutes: Int,
|
rawMinutes: Int,
|
||||||
startDate: LocalDate,
|
startDate: LocalDate,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
timeOfDayMinutes: Int,
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -261,11 +261,13 @@ interface CalendarDataSource {
|
|||||||
* without gets the occurrence excluded from the parent via EXDATE plus a
|
* without gets the occurrence excluded from the parent via EXDATE plus a
|
||||||
* standalone event carrying the edits — an exception cannot link to its
|
* standalone event carrying the edits — an exception cannot link to its
|
||||||
* parent there (Codeberg #234, the same constraint as [deleteOccurrence]).
|
* 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(
|
fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long
|
): Long
|
||||||
@@ -878,7 +880,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
put(CalendarContract.Reminders.MINUTES, providerMinutes)
|
put(CalendarContract.Reminders.MINUTES, providerMinutes)
|
||||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
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")
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1222,14 +1234,20 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
override fun updateOccurrence(
|
override fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long {
|
): Long {
|
||||||
val row = querySeriesRow(eventId)
|
val row = querySeriesRow(eventId)
|
||||||
// Stricter than deleteOccurrence's bare _sync_id check: EXDATE only means
|
// Not a series, so there is no occurrence to single out: the row *is* the
|
||||||
// something on a row that recurs, so a non-recurring one keeps the
|
// event, and an exception with nothing to be an exception to would leave
|
||||||
// exception path rather than getting a recurrence set written onto it.
|
// an orphan holding the edit (the same test deleteOccurrence makes).
|
||||||
if (row.syncId == null && !row.rrule.isNullOrBlank()) {
|
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)
|
return detachOccurrence(eventId, beginMillis, row, form, allDayReminderTimeMinutes)
|
||||||
}
|
}
|
||||||
// The provider clones the series row and applies these values on top.
|
// 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
|
// 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
|
// parent: the EXDATE merge would fold the repeat away and still report a
|
||||||
// changed row, quietly leaving a *second* standalone copy.
|
// 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)
|
throw NoSuchEventException(eventId)
|
||||||
}
|
}
|
||||||
// Reminders, guests and colour come along like any new event, and so does
|
// Reminders, guests and colour come along like any new event, and so does
|
||||||
@@ -1297,6 +1315,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
occurrenceMillis = beginMillis,
|
occurrenceMillis = beginMillis,
|
||||||
dtStartMillis = row.dtStartMillis,
|
dtStartMillis = row.dtStartMillis,
|
||||||
rrule = row.rrule,
|
rrule = row.rrule,
|
||||||
|
rdate = row.rdate,
|
||||||
duration = row.duration,
|
duration = row.duration,
|
||||||
timezone = row.timezone,
|
timezone = row.timezone,
|
||||||
allDay = row.allDay,
|
allDay = row.allDay,
|
||||||
@@ -1381,7 +1400,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
// exclusion means nothing on a row that doesn't recur.
|
// exclusion means nothing on a row that doesn't recur.
|
||||||
if (updated.rrule.isNullOrBlank()) return
|
if (updated.rrule.isNullOrBlank()) return
|
||||||
val carried = shiftedExdate(
|
val carried = shiftedExdate(
|
||||||
existingExdate = exdateAfter(parent.exdate, beginMillis, original.isAllDay),
|
existingExdate = exdateAfter(
|
||||||
|
parent.exdate,
|
||||||
|
beginMillis,
|
||||||
|
original.isAllDay,
|
||||||
|
parent.timezone,
|
||||||
|
),
|
||||||
original = original,
|
original = original,
|
||||||
updated = updated,
|
updated = updated,
|
||||||
zone = ZoneId.systemDefault(),
|
zone = ZoneId.systemDefault(),
|
||||||
@@ -1461,6 +1485,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Events.ALL_DAY,
|
CalendarContract.Events.ALL_DAY,
|
||||||
CalendarContract.Events._SYNC_ID,
|
CalendarContract.Events._SYNC_ID,
|
||||||
CalendarContract.Events.EXDATE,
|
CalendarContract.Events.EXDATE,
|
||||||
|
CalendarContract.Events.RDATE,
|
||||||
),
|
),
|
||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
@@ -1473,6 +1498,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
allDay = c.getInt(4),
|
allDay = c.getInt(4),
|
||||||
syncId = c.getString(5),
|
syncId = c.getString(5),
|
||||||
exdate = c.getString(6),
|
exdate = c.getString(6),
|
||||||
|
rdate = c.getString(7),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -1488,7 +1514,16 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
/** Null on a local calendar (and before a synced event's first push). */
|
/** Null on a local calendar (and before a synced event's first push). */
|
||||||
val syncId: String? = null,
|
val syncId: String? = null,
|
||||||
val exdate: 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]. */
|
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||||
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||||
beginMillis = beginMillis,
|
beginMillis = beginMillis,
|
||||||
@@ -1679,6 +1714,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
val row = querySeriesRow(eventId)
|
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) {
|
if (row.syncId == null) {
|
||||||
// No _sync_id — a local calendar, or a synced event not pushed yet.
|
// No _sync_id — a local calendar, or a synced event not pushed yet.
|
||||||
// A cancelled exception can only attach to its parent through
|
// A cancelled exception can only attach to its parent through
|
||||||
@@ -1692,6 +1733,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
occurrenceMillis = beginMillis,
|
occurrenceMillis = beginMillis,
|
||||||
dtStartMillis = row.dtStartMillis,
|
dtStartMillis = row.dtStartMillis,
|
||||||
rrule = row.rrule,
|
rrule = row.rrule,
|
||||||
|
rdate = row.rdate,
|
||||||
duration = row.duration,
|
duration = row.duration,
|
||||||
timezone = row.timezone,
|
timezone = row.timezone,
|
||||||
allDay = row.allDay,
|
allDay = row.allDay,
|
||||||
|
|||||||
@@ -88,9 +88,15 @@ interface CalendarRepository {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event (exception row with the
|
* 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);
|
* Change a recurring event from [beginMillis] onwards (series split);
|
||||||
|
|||||||
@@ -228,7 +228,8 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
var imported = 0
|
var imported = 0
|
||||||
var skipped = 0
|
var skipped = 0
|
||||||
var failed = 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,
|
// A known UID means the event is already in this calendar — skip,
|
||||||
// keeping a restore idempotent (no overwrite this pass).
|
// keeping a restore idempotent (no overwrite this pass).
|
||||||
if (event.uid != null && event.uid in existing) {
|
if (event.uid != null && event.uid in existing) {
|
||||||
@@ -240,6 +241,17 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
imported++
|
imported++
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
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) {
|
} catch (e: Exception) {
|
||||||
// One event the provider won't take must not cost the user the
|
// One event the provider won't take must not cost the user the
|
||||||
// rest of the file — foreign exports do carry rows it rejects
|
// 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)
|
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) {
|
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||||
@@ -281,9 +298,12 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun updateOccurrence(
|
override suspend fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = withContext(io) {
|
||||||
dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
|
dataSource.updateOccurrence(
|
||||||
|
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateEventFromOccurrence(
|
override suspend fun updateEventFromOccurrence(
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import java.time.Duration
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
|
import java.time.ZonedDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.ResolverStyle
|
import java.time.format.ResolverStyle
|
||||||
import java.time.LocalDate as JavaLocalDate
|
import java.time.LocalDate as JavaLocalDate
|
||||||
import java.time.LocalDateTime as JavaLocalDateTime
|
import java.time.LocalDateTime as JavaLocalDateTime
|
||||||
|
import java.time.LocalTime as JavaLocalTime
|
||||||
|
|
||||||
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||||
internal data class EventWriteTimes(
|
internal data class EventWriteTimes(
|
||||||
@@ -443,29 +445,69 @@ internal fun buildOccurrenceCancelValues(
|
|||||||
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
|
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
|
||||||
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
|
* 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
|
* 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`
|
* of the same occurrence is folded away — [mergedExdate]).
|
||||||
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`).
|
|
||||||
*/
|
*/
|
||||||
internal fun buildOccurrenceExdateValues(
|
internal fun buildOccurrenceExdateValues(
|
||||||
existingExdate: String?,
|
existingExdate: String?,
|
||||||
occurrenceMillis: Long,
|
occurrenceMillis: Long,
|
||||||
dtStartMillis: Long,
|
dtStartMillis: Long,
|
||||||
rrule: String?,
|
rrule: String?,
|
||||||
|
rdate: String?,
|
||||||
duration: String?,
|
duration: String?,
|
||||||
timezone: String?,
|
timezone: String?,
|
||||||
allDay: Int,
|
allDay: Int,
|
||||||
): Map<String, Any?> {
|
): Map<String, Any?> = mapOf(
|
||||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
|
CalendarContract.Events.EXDATE to
|
||||||
val merged = (exdateStamps(existingExdate) + stamp).distinct().joinToString(",")
|
mergedExdate(existingExdate, occurrenceMillis, allDay != 0, timezone),
|
||||||
return mapOf(
|
CalendarContract.Events.DTSTART to dtStartMillis,
|
||||||
CalendarContract.Events.EXDATE to merged,
|
CalendarContract.Events.RRULE to rrule,
|
||||||
CalendarContract.Events.DTSTART to dtStartMillis,
|
CalendarContract.Events.RDATE to rdate,
|
||||||
CalendarContract.Events.RRULE to rrule,
|
CalendarContract.Events.DURATION to duration,
|
||||||
CalendarContract.Events.DURATION to duration,
|
CalendarContract.Events.EVENT_TIMEZONE to timezone,
|
||||||
CalendarContract.Events.EVENT_TIMEZONE to timezone,
|
CalendarContract.Events.ALL_DAY to allDay,
|
||||||
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?,
|
existingExdate: String?,
|
||||||
occurrenceMillis: Long,
|
occurrenceMillis: Long,
|
||||||
isAllDay: Boolean,
|
isAllDay: Boolean,
|
||||||
|
timezone: String?,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
|
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
|
* [original] and written from [updated], so the `VALUE=DATE` and date-time forms
|
||||||
* convert into each other when the event switches.
|
* convert into each other when the event switches.
|
||||||
*
|
*
|
||||||
* Null when there is nothing to carry: no stamps, or a stamp in a form Calendula
|
* A zoned list is read in the zone its prefix names ([exdateListZone]). Null
|
||||||
* never writes (a `TZID=`-parameterised or floating one from a sync adapter). Those
|
* when there is nothing to carry: no stamps, or a stamp in a form Calendula
|
||||||
* are left exactly as they are rather than guessed at — a stale stamp excludes
|
* cannot read at all. Those are left exactly as they are rather than guessed at —
|
||||||
* nothing, but a mangled one could exclude the wrong occurrence.
|
* a stale stamp excludes nothing, but a mangled one could exclude the wrong
|
||||||
|
* occurrence.
|
||||||
*/
|
*/
|
||||||
internal fun shiftedExdate(
|
internal fun shiftedExdate(
|
||||||
existingExdate: String?,
|
existingExdate: String?,
|
||||||
@@ -514,10 +563,12 @@ internal fun shiftedExdate(
|
|||||||
if (stamps.isEmpty()) return null
|
if (stamps.isEmpty()) return null
|
||||||
val fromZone = original.writeZone(zone)
|
val fromZone = original.writeZone(zone)
|
||||||
val toZone = updated.writeZone(zone)
|
val toZone = updated.writeZone(zone)
|
||||||
|
val listZone = exdateListZone(existingExdate, fromZone.id)
|
||||||
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
|
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
|
||||||
return stamps
|
return stamps
|
||||||
.map { stamp ->
|
.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)
|
formatExdateStamp(local.plus(wallClockShift), updated.isAllDay, toZone)
|
||||||
}
|
}
|
||||||
.distinct()
|
.distinct()
|
||||||
@@ -534,27 +585,97 @@ internal fun shiftedExdate(
|
|||||||
* is editing, so it exists by definition, and honouring a stale exclusion for it
|
* is editing, so it exists by definition, and honouring a stale exclusion for it
|
||||||
* would swallow the edit whole.
|
* 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]).
|
* 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)
|
val stamps = exdateStamps(existingExdate)
|
||||||
if (stamps.isEmpty()) return null
|
if (stamps.isEmpty()) return null
|
||||||
|
val listZone = exdateListZone(existingExdate, timezone)
|
||||||
return stamps
|
return stamps
|
||||||
.filter { stamp ->
|
.mapNotNull { stamp ->
|
||||||
val utc = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC) ?: return null
|
val utc = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC, listZone) ?: return null
|
||||||
utc.toInstant(ZoneOffset.UTC).toEpochMilli() > beginMillis
|
utc.takeIf { it.toInstant(ZoneOffset.UTC).toEpochMilli() > beginMillis }
|
||||||
}
|
}
|
||||||
|
.map { formatExdateStamp(it, isAllDay, ZoneOffset.UTC) }
|
||||||
.takeIf { it.isNotEmpty() }
|
.takeIf { it.isNotEmpty() }
|
||||||
?.joinToString(",")
|
?.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
|
private fun exdateStamps(exdate: String?): List<String> = exdate
|
||||||
|
?.withoutExdateParameters()
|
||||||
?.split(',')
|
?.split(',')
|
||||||
?.map { it.trim() }
|
?.map { it.trim() }
|
||||||
?.filter { it.isNotEmpty() }
|
?.filter { it.isNotEmpty() }
|
||||||
.orEmpty()
|
.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
|
* 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
|
* 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
|
* [stamp] as the wall clock it names in [zone] — the inverse of
|
||||||
* [formatExdateStamp]. Null for anything but the two forms Calendula writes, so a
|
* [formatExdateStamp]. Null for anything Calendula cannot read, so a caller can
|
||||||
* caller can tell "not ours, leave it alone" from a value it may safely re-time.
|
* 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? =
|
private fun parseExdateStamp(
|
||||||
runCatching {
|
stamp: String,
|
||||||
if (isAllDay) {
|
isAllDay: Boolean,
|
||||||
JavaLocalDate.parse(stamp, ALL_DAY_EXDATE).atStartOfDay()
|
zone: ZoneId,
|
||||||
} else {
|
listZone: ZoneId,
|
||||||
JavaLocalDateTime.parse(stamp, TIMED_EXDATE)
|
): JavaLocalDateTime? =
|
||||||
.atZone(ZoneOffset.UTC).withZoneSameInstant(zone).toLocalDateTime()
|
if (isAllDay) {
|
||||||
}
|
parseAllDayExdate(stamp)?.atStartOfDay()
|
||||||
}.getOrNull()
|
} 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. */
|
/** `VALUE=DATE` EXDATE form, for an all-day series. */
|
||||||
private val ALL_DAY_EXDATE: DateTimeFormatter =
|
private val ALL_DAY_EXDATE: DateTimeFormatter =
|
||||||
@@ -602,6 +771,10 @@ private val ALL_DAY_EXDATE: DateTimeFormatter =
|
|||||||
private val TIMED_EXDATE: DateTimeFormatter =
|
private val TIMED_EXDATE: DateTimeFormatter =
|
||||||
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss'Z'").withResolverStyle(ResolverStyle.STRICT)
|
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
|
* 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
|
||||||
|
|||||||
@@ -59,7 +59,11 @@ internal fun ColumnReader.toIcsEvent(
|
|||||||
exDates = if (rrule == null) {
|
exDates = if (rrule == null) {
|
||||||
emptyList()
|
emptyList()
|
||||||
} else {
|
} else {
|
||||||
exportExDates(getString(EventExportProjection.IDX_EXDATE), isAllDay)
|
exportExDates(
|
||||||
|
getString(EventExportProjection.IDX_EXDATE),
|
||||||
|
isAllDay,
|
||||||
|
getString(EventExportProjection.IDX_EVENT_TIMEZONE),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
location = getString(EventExportProjection.IDX_LOCATION),
|
location = getString(EventExportProjection.IDX_LOCATION),
|
||||||
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
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
|
* 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.
|
* stamp; the time part is dropped so the exported value is the day either way.
|
||||||
* Calendula's own writes ([buildOccurrenceExdateValues]) are already bare.
|
* 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
|
internal fun exportExDates(
|
||||||
?.split(',')
|
exdate: String?,
|
||||||
?.map { it.trim() }
|
isAllDay: Boolean,
|
||||||
?.filter { it.isNotEmpty() }
|
eventTimezone: String? = null,
|
||||||
?.map { if (isAllDay) it.substringBefore('T') else it }
|
): List<String> {
|
||||||
?.distinct()
|
if (exdate.isNullOrBlank()) return emptyList()
|
||||||
.orEmpty()
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 } }
|
||||||
@@ -7,6 +7,7 @@ import android.util.Log
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
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.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
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 occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
||||||
val planned = planReminders(
|
val planned = planReminders(
|
||||||
instances = occurrences,
|
instances = occurrences,
|
||||||
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }),
|
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId })
|
||||||
|
.withoutProviderDefaults(),
|
||||||
zone = ZoneId.systemDefault(),
|
zone = ZoneId.systemDefault(),
|
||||||
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
|
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -110,13 +110,25 @@ data class IcsParseResult(
|
|||||||
* Outcome of a bulk `.ics` import into one calendar. [failed] counts events the
|
* Outcome of a bulk `.ics` import into one calendar. [failed] counts events the
|
||||||
* provider rejected — the import continues past them, so a single unusable
|
* provider rejected — the import continues past them, so a single unusable
|
||||||
* event can't cost the user the rest of the file.
|
* 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(
|
data class IcsImportSummary(
|
||||||
val imported: Int,
|
val imported: Int,
|
||||||
val skippedDuplicate: Int,
|
val skippedDuplicate: Int,
|
||||||
val failed: Int = 0,
|
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
|
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
||||||
* JVM-testable. Liberal-in/strict-out: unknown properties are ignored, a single
|
* 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 lines = unfoldLines(text)
|
||||||
val events = mutableListOf<ParsedIcsEvent>()
|
val events = mutableListOf<ParsedIcsEvent>()
|
||||||
val warnings = mutableSetOf<IcsParseWarning>()
|
val warnings = mutableSetOf<IcsParseWarning>()
|
||||||
// Scanned up front rather than in document order: an X-WR-CALNAME after
|
// Scanned up front, not in document order: an X-WR-CALNAME after the
|
||||||
// the first VEVENT still names the calendar every event came from.
|
// 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()
|
val calendarName = lines.asSequence()
|
||||||
|
.filter { it.startsWith(CALENDAR_NAME_PROPERTY, ignoreCase = true) }
|
||||||
.mapNotNull(::parseContentLine)
|
.mapNotNull(::parseContentLine)
|
||||||
.firstOrNull { it.name == "X-WR-CALNAME" }
|
.firstOrNull { it.name == CALENDAR_NAME_PROPERTY }
|
||||||
?.let { unescapeText(it.value).trim().ifEmpty { null } }
|
?.let { unescapeText(it.value).trim().ifEmpty { null } }
|
||||||
|
|
||||||
var i = 0
|
var i = 0
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ private const val MINUTES_PER_DAY = 1_440
|
|||||||
* for *which day* it means ([allDayLeadDays]) and take the hour from
|
* for *which day* it means ([allDayLeadDays]) and take the hour from
|
||||||
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
|
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
|
||||||
* Duplicate offsets in [minutesByEvent] collapse.
|
* 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(
|
fun planReminders(
|
||||||
instances: List<ReminderEventInstance>,
|
instances: List<ReminderEventInstance>,
|
||||||
@@ -70,17 +76,19 @@ fun planReminders(
|
|||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
allDayTimeMinutes: Int,
|
allDayTimeMinutes: Int,
|
||||||
): List<PlannedReminder> = instances.flatMap { instance ->
|
): List<PlannedReminder> = instances.flatMap { instance ->
|
||||||
minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes ->
|
minutesByEvent[instance.eventId].orEmpty()
|
||||||
PlannedReminder(
|
.distinct()
|
||||||
instance = instance,
|
.map { minutes ->
|
||||||
minutes = minutes,
|
PlannedReminder(
|
||||||
alarmMillis = if (instance.isAllDay) {
|
instance = instance,
|
||||||
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
|
minutes = minutes,
|
||||||
} else {
|
alarmMillis = if (instance.isAllDay) {
|
||||||
instance.beginMillis - minutes * MILLIS_PER_MINUTE
|
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. */
|
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ fun CalendarHost(
|
|||||||
EventMoveScope(
|
EventMoveScope(
|
||||||
movableCalendarIds = movableCalendarIds,
|
movableCalendarIds = movableCalendarIds,
|
||||||
dragEnabled = dragToReschedule,
|
dragEnabled = dragToReschedule,
|
||||||
move = reschedule::move,
|
move = { reschedule.move(it) },
|
||||||
inFlight = reschedule.inFlight,
|
inFlight = reschedule.inFlight,
|
||||||
undoStarted = reschedule.undoStarted,
|
undoStarted = reschedule.undoStarted,
|
||||||
abandoned = reschedule.abandoned,
|
abandoned = reschedule.abandoned,
|
||||||
|
|||||||
@@ -35,8 +35,12 @@ class EventMoveScope(
|
|||||||
* accidental drags the setting exists to stop.
|
* accidental drags the setting exists to stop.
|
||||||
*/
|
*/
|
||||||
val dragEnabled: Boolean,
|
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
|
* 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
|
* dialog is up. A flow rather than a value so this scope stays the same
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.MINUTES_DEFAULT
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
|
||||||
/** Common reminder lead times offered as quick picks in the form and settings. */
|
/** 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
|
* Humanise a reminder lead time (minutes before the event start) into one
|
||||||
* line: "Default reminder" (negative = the provider default), "At time of
|
* line: "Default reminder" ([MINUTES_DEFAULT]), "At time of event" (0),
|
||||||
* event" (0), "10 minutes before", "1 hour before", … Shared by the detail
|
* "10 minutes before", "1 hour before", … Shared by the detail screen, the event
|
||||||
* screen, the event form and the default-reminder settings so the wording
|
* form and the default-reminder settings so the wording never drifts.
|
||||||
* 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
|
@Composable
|
||||||
fun reminderLeadTimeLabel(minutes: Int): String = when {
|
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_at_time)
|
||||||
|
minutes < 0 -> stringResource(R.string.reminder_after, durationLabel(-minutes))
|
||||||
minutes % 10_080 == 0 ->
|
minutes % 10_080 == 0 ->
|
||||||
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
||||||
minutes % 1_440 == 0 ->
|
minutes % 1_440 == 0 ->
|
||||||
|
|||||||
@@ -202,6 +202,10 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
* Take a drop, unless one is already being written. False means nothing will
|
* 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
|
* 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.
|
* 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 {
|
fun move(request: MoveRequest): Boolean {
|
||||||
if (busy || _scopePrompt.value != null) {
|
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
|
* inverse write reports back, so the one chip changes what it says rather
|
||||||
* than closing and reopening. False when another write is already running —
|
* than closing and reopening. False when another write is already running —
|
||||||
* the chip hides its action for that window, so this is the backstop.
|
* 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 {
|
fun undo(undo: MoveUndo): Boolean {
|
||||||
if (busy) return false
|
if (busy) return false
|
||||||
@@ -272,6 +282,9 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
MoveOutcome.Failed
|
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
|
busy = false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -368,6 +381,7 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
repository.updateOccurrence(
|
repository.updateOccurrence(
|
||||||
request.eventId,
|
request.eventId,
|
||||||
request.beginMillis,
|
request.beginMillis,
|
||||||
|
prepared.original,
|
||||||
prepared.updated,
|
prepared.updated,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ data class TimelineDrag(
|
|||||||
val eventSpanMin: Int,
|
val eventSpanMin: Int,
|
||||||
/** The event as the grid would draw it, one piece per day column it covers. */
|
/** The event as the grid would draw it, one piece per day column it covers. */
|
||||||
val pieces: List<TimelineDragPiece>,
|
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. */
|
/** What the snap haptics key off: one tick per changed slot, not per frame. */
|
||||||
val slot: Pair<LocalDate, Int> get() = date to startMin
|
val slot: Pair<LocalDate, Int> get() = date to startMin
|
||||||
@@ -117,9 +123,9 @@ internal fun dragSlices(
|
|||||||
): List<DragSlice> {
|
): List<DragSlice> {
|
||||||
val span = spanMin.coerceAtLeast(0)
|
val span = spanMin.coerceAtLeast(0)
|
||||||
val end = eventStartMin + span
|
val end = eventStartMin + span
|
||||||
val first = eventStartMin.floorDiv(MINUTES_PER_DAY)
|
val days = dragDayRange(eventStartMin, spanMin)
|
||||||
// A zero-length event still has a block, on the one day it sits in.
|
val first = days.first
|
||||||
val last = maxOf((end - 1).floorDiv(MINUTES_PER_DAY), first)
|
val last = days.last
|
||||||
val from = maxOf(first, within?.first ?: first)
|
val from = maxOf(first, within?.first ?: first)
|
||||||
val to = minOf(last, within?.last ?: last)
|
val to = minOf(last, within?.last ?: last)
|
||||||
if (from > to) return emptyList()
|
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
|
* 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
|
* 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
|
val clipOffset = clipOffsetMin
|
||||||
cancel()
|
cancel()
|
||||||
if (landed == null || landed.slot == origin) return null
|
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
|
liftedInstanceId = landed.event.instanceId
|
||||||
settledOrigin = landed.event.eventId to landed.event.start
|
settledOrigin = landed.event.eventId to landed.event.start
|
||||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||||
@@ -413,6 +456,11 @@ class TimelineDragController {
|
|||||||
val eventStartMin = startMin - clipOffsetMin
|
val eventStartMin = startMin - clipOffsetMin
|
||||||
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
||||||
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
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(
|
drag = TimelineDrag(
|
||||||
event = block.event,
|
event = block.event,
|
||||||
date = days[dayIndex],
|
date = days[dayIndex],
|
||||||
@@ -421,29 +469,27 @@ class TimelineDragController {
|
|||||||
eventSpanMin = eventSpan,
|
eventSpanMin = eventSpan,
|
||||||
// Bounded to the columns this timeline actually shows: a day it
|
// Bounded to the columns this timeline actually shows: a day it
|
||||||
// doesn't has no piece to draw. The write is unaffected.
|
// doesn't has no piece to draw. The write is unaffected.
|
||||||
pieces = dragSlices(
|
pieces = slices
|
||||||
eventStartMin,
|
.map { slice ->
|
||||||
eventSpan,
|
val day = dayIndex + slice.dayOffset
|
||||||
within = -dayIndex..days.lastIndex - dayIndex,
|
val col = if (geometry.isRtl) days.lastIndex - day else day
|
||||||
).map { slice ->
|
TimelineDragPiece(
|
||||||
val day = dayIndex + slice.dayOffset
|
topLeftInRoot = Offset(
|
||||||
val col = if (geometry.isRtl) days.lastIndex - day else day
|
x = origin.x + col * columnPx,
|
||||||
TimelineDragPiece(
|
y = origin.y + slice.startMin / 60f * hourPx,
|
||||||
topLeftInRoot = Offset(
|
),
|
||||||
x = origin.x + col * columnPx,
|
sizePx = IntSize(
|
||||||
y = origin.y + slice.startMin / 60f * hourPx,
|
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||||
),
|
maxOf(
|
||||||
sizePx = IntSize(
|
slice.spanMin / 60f * hourPx,
|
||||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
MIN_EVENT_FRACTION * hourPx,
|
||||||
maxOf(
|
).roundToInt(),
|
||||||
slice.spanMin / 60f * hourPx,
|
),
|
||||||
MIN_EVENT_FRACTION * hourPx,
|
continuesBefore = slice.continuesBefore,
|
||||||
).roundToInt(),
|
continuesAfter = slice.continuesAfter,
|
||||||
),
|
)
|
||||||
continuesBefore = slice.continuesBefore,
|
},
|
||||||
continuesAfter = slice.continuesAfter,
|
edgeOnly = onGrid.isEmpty(),
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -737,7 +737,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
when (scope) {
|
when (scope) {
|
||||||
RecurringWriteScope.ThisEvent ->
|
RecurringWriteScope.ThisEvent ->
|
||||||
repository.updateOccurrence(target.eventId, target.beginMillis, form)
|
repository.updateOccurrence(
|
||||||
|
target.eventId,
|
||||||
|
target.beginMillis,
|
||||||
|
target.original,
|
||||||
|
form,
|
||||||
|
)
|
||||||
RecurringWriteScope.ThisAndFollowing ->
|
RecurringWriteScope.ThisAndFollowing ->
|
||||||
repository.updateEventFromOccurrence(
|
repository.updateEventFromOccurrence(
|
||||||
eventId = target.eventId,
|
eventId = target.eventId,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.PriorityHigh
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
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(
|
Column(
|
||||||
Modifier.fillMaxSize().padding(24.dp),
|
Modifier.fillMaxSize().padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -232,19 +237,31 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
|||||||
scaleY = badgeScale.value
|
scaleY = badgeScale.value
|
||||||
}
|
}
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
.background(
|
||||||
|
if (stopped) {
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
},
|
||||||
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Check,
|
if (stopped) Icons.Default.PriorityHigh else Icons.Default.Check,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
tint = if (stopped) {
|
||||||
|
MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
},
|
||||||
modifier = Modifier.size(56.dp),
|
modifier = Modifier.size(56.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
@@ -266,6 +283,21 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
|||||||
textAlign = TextAlign.Center,
|
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))
|
Spacer(Modifier.height(24.dp))
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.widget
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.SystemClock
|
||||||
import androidx.glance.appwidget.updateAll
|
import androidx.glance.appwidget.updateAll
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
@@ -10,6 +11,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redraws both home-screen widgets when their data goes stale, and keeps the
|
* 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
|
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the
|
||||||
* latter is also what arms installs upgrading into the fix.
|
* latter is also what arms installs upgrading into the fix.
|
||||||
*
|
*
|
||||||
* `DATE_CHANGED` is a free extra in the filter that nothing depends on — see
|
* `DATE_CHANGED` rides along as a free extra the system may or may not send —
|
||||||
* [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
|
* see [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
|
||||||
* provider XML and the month widget's refresh button.
|
* 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
|
* Exported for the system broadcasts; an extra redraw from another app is
|
||||||
* harmless.
|
* harmless.
|
||||||
*/
|
*/
|
||||||
@@ -38,22 +48,28 @@ class WidgetUpdateReceiver : BroadcastReceiver() {
|
|||||||
// for keeps a stray broadcast from costing two wide provider reads.
|
// for keeps a stray broadcast from costing two wide provider reads.
|
||||||
if (intent.action !in HANDLED_ACTIONS) return
|
if (intent.action !in HANDLED_ACTIONS) return
|
||||||
val appContext = context.applicationContext
|
val appContext = context.applicationContext
|
||||||
// Re-arm first, so the next day boundary is covered whatever the redraw
|
val rearm = intent.action in REARM_ACTIONS ||
|
||||||
// does. Every handled action either dropped, consumed or invalidated it.
|
(intent.action == Intent.ACTION_PROVIDER_CHANGED && dataRearmDue())
|
||||||
WidgetRolloverScheduler.sync(appContext)
|
// The host sends APPWIDGET_UPDATE after the re-arm-only ones anyway, so
|
||||||
// The host sends APPWIDGET_UPDATE after both of these anyway, so
|
// redrawing there would only repeat the work in a cold process, at the
|
||||||
// redrawing here would only repeat the work in a cold process, at the
|
|
||||||
// moment the device is most contended.
|
// 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()
|
val pending = goAsync()
|
||||||
// Calendar data may have changed (sync / our own write) — drop the cached
|
// Calendar data may have changed (sync / our own write) — drop the cached
|
||||||
// month window so the widgets reload fresh. Month paging does NOT call
|
// month window so the widgets reload fresh. Month paging does NOT call
|
||||||
// this, so arrow taps stay instant.
|
// this, so arrow taps stay instant.
|
||||||
invalidateMonthWidgetCache()
|
if (redraw) invalidateMonthWidgetCache()
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
AgendaWidget().updateAll(appContext)
|
// Re-armed first, so the next day boundary is covered whatever
|
||||||
MonthWidget().updateAll(appContext)
|
// 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 {
|
} finally {
|
||||||
pending.finish()
|
pending.finish()
|
||||||
}
|
}
|
||||||
@@ -70,12 +86,35 @@ class WidgetUpdateReceiver : BroadcastReceiver() {
|
|||||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
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,
|
ACTION_ROLLOVER,
|
||||||
Intent.ACTION_PROVIDER_CHANGED,
|
|
||||||
Intent.ACTION_DATE_CHANGED,
|
|
||||||
Intent.ACTION_TIME_CHANGED,
|
Intent.ACTION_TIME_CHANGED,
|
||||||
Intent.ACTION_TIMEZONE_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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,6 +231,8 @@
|
|||||||
<string name="event_detail_self_response">Your response: %1$s</string>
|
<string name="event_detail_self_response">Your response: %1$s</string>
|
||||||
<string name="reminder_at_time">At time of event</string>
|
<string name="reminder_at_time">At time of event</string>
|
||||||
<string name="reminder_default">Default reminder</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">
|
<plurals name="reminder_minutes">
|
||||||
<item quantity="one">%d minute before</item>
|
<item quantity="one">%d minute before</item>
|
||||||
<item quantity="other">%d minutes 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_skipped_label">Duplicates</string>
|
||||||
<string name="import_done_failed_label">Not added</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_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_close">Close</string>
|
||||||
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</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>
|
<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="one">Import %d event</item>
|
||||||
<item quantity="other">Import %d events</item>
|
<item quantity="other">Import %d events</item>
|
||||||
</plurals>
|
</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">
|
<plurals name="import_done_imported">
|
||||||
<item quantity="one">Imported %d event.</item>
|
<item quantity="one">Imported %d event.</item>
|
||||||
<item quantity="other">Imported %d events.</item>
|
<item quantity="other">Imported %d events.</item>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<locale android:name="en" />
|
<locale android:name="en" />
|
||||||
<locale android:name="ar" />
|
<locale android:name="ar" />
|
||||||
|
<locale android:name="be" />
|
||||||
<locale android:name="cs" />
|
<locale android:name="cs" />
|
||||||
<locale android:name="de" />
|
<locale android:name="de" />
|
||||||
<locale android:name="es" />
|
<locale android:name="es" />
|
||||||
@@ -19,5 +20,6 @@
|
|||||||
<locale android:name="pl" />
|
<locale android:name="pl" />
|
||||||
<locale android:name="pt-BR" />
|
<locale android:name="pt-BR" />
|
||||||
<locale android:name="ru" />
|
<locale android:name="ru" />
|
||||||
|
<locale android:name="sk" />
|
||||||
<locale android:name="zh-CN" />
|
<locale android:name="zh-CN" />
|
||||||
</locale-config>
|
</locale-config>
|
||||||
|
|||||||
@@ -184,6 +184,35 @@ class AllDayReminderEncodingTest {
|
|||||||
assertThat(importedAllDayReminderDate(future, "FREQ=WEEKLY", today)).isEqualTo(future)
|
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
|
@Test
|
||||||
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
|
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
|
||||||
// Known limitation: one fixed MINUTES per series can't track DST. An
|
// Known limitation: one fixed MINUTES per series can't track DST. An
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
|||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
import org.junit.jupiter.api.io.TempDir
|
import org.junit.jupiter.api.io.TempDir
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
||||||
@@ -642,7 +643,12 @@ class CalendarRepositoryImplTest {
|
|||||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
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(id).isEqualTo(88L)
|
||||||
assertThat(fake.updatedOccurrences).containsExactly(Triple(42L, 1_000L, form))
|
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")
|
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
|
@Test
|
||||||
fun `importEvents looks the target palette up once, not per event`(
|
fun `importEvents looks the target palette up once, not per event`(
|
||||||
@TempDir tempDir: Path,
|
@TempDir tempDir: Path,
|
||||||
|
|||||||
@@ -498,6 +498,7 @@ class EventWriteMapperTest {
|
|||||||
occurrenceMillis = 1_784_102_400_000L,
|
occurrenceMillis = 1_784_102_400_000L,
|
||||||
dtStartMillis = 1_783_929_600_000L,
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
rrule = "FREQ=DAILY;COUNT=5",
|
rrule = "FREQ=DAILY;COUNT=5",
|
||||||
|
rdate = null,
|
||||||
duration = "PT1H",
|
duration = "PT1H",
|
||||||
timezone = "Europe/Berlin",
|
timezone = "Europe/Berlin",
|
||||||
allDay = 0,
|
allDay = 0,
|
||||||
@@ -521,6 +522,7 @@ class EventWriteMapperTest {
|
|||||||
occurrenceMillis = 1_784_102_400_000L,
|
occurrenceMillis = 1_784_102_400_000L,
|
||||||
dtStartMillis = 1_783_929_600_000L,
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
rrule = "FREQ=DAILY;COUNT=5",
|
rrule = "FREQ=DAILY;COUNT=5",
|
||||||
|
rdate = null,
|
||||||
duration = "PT1H",
|
duration = "PT1H",
|
||||||
timezone = "Europe/Berlin",
|
timezone = "Europe/Berlin",
|
||||||
allDay = 0,
|
allDay = 0,
|
||||||
@@ -536,6 +538,7 @@ class EventWriteMapperTest {
|
|||||||
occurrenceMillis = 1_784_102_400_000L,
|
occurrenceMillis = 1_784_102_400_000L,
|
||||||
dtStartMillis = 1_783_929_600_000L,
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
rrule = "FREQ=DAILY;COUNT=5",
|
rrule = "FREQ=DAILY;COUNT=5",
|
||||||
|
rdate = null,
|
||||||
duration = "PT1H",
|
duration = "PT1H",
|
||||||
timezone = "Europe/Berlin",
|
timezone = "Europe/Berlin",
|
||||||
allDay = 0,
|
allDay = 0,
|
||||||
@@ -543,6 +546,27 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
|
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
|
@Test
|
||||||
fun `all-day exdate drop uses the date-only form`() {
|
fun `all-day exdate drop uses the date-only form`() {
|
||||||
// An all-day DTSTART sits at UTC midnight, so the date reads off UTC.
|
// 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
|
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
|
||||||
dtStartMillis = 1_783_900_800_000L,
|
dtStartMillis = 1_783_900_800_000L,
|
||||||
rrule = "FREQ=YEARLY",
|
rrule = "FREQ=YEARLY",
|
||||||
|
rdate = null,
|
||||||
duration = "P1D",
|
duration = "P1D",
|
||||||
timezone = "UTC",
|
timezone = "UTC",
|
||||||
allDay = 1,
|
allDay = 1,
|
||||||
@@ -559,6 +584,90 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
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) ---
|
// --- EXDATE follows the series when its times change (Codeberg #248) ---
|
||||||
|
|
||||||
/** A weekly series whose displayed occurrence runs 15 July 2026, 09:00–10:00. */
|
/** A weekly series whose displayed occurrence runs 15 July 2026, 09:00–10:00. */
|
||||||
@@ -627,6 +736,65 @@ class EventWriteMapperTest {
|
|||||||
.isEqualTo("20260724")
|
.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
|
@Test
|
||||||
fun `switching a series to all-day rewrites its exclusions as dates`() {
|
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
|
// The two forms aren't interchangeable: a date-time stamp on an all-day
|
||||||
@@ -673,9 +841,10 @@ class EventWriteMapperTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an exdate form we do not write is left untouched`() {
|
fun `a zoned exdate list is read in the zone its prefix names`() {
|
||||||
// A sync adapter may store a TZID-parameterised or floating stamp. A stale
|
// AOSP's RecurrenceSet form, TZID=<zone>;<floating stamps>. Read as UTC the
|
||||||
// stamp excludes nothing; a mangled one could exclude the wrong occurrence.
|
// stamps name the wrong instants; skipped, every exclusion of the series is
|
||||||
|
// dropped the moment it is re-timed.
|
||||||
val original = julySeries()
|
val original = julySeries()
|
||||||
val values = update(
|
val values = update(
|
||||||
original,
|
original,
|
||||||
@@ -683,6 +852,35 @@ class EventWriteMapperTest {
|
|||||||
instantAt("2026-07-01T09:00", "Europe/Berlin"),
|
instantAt("2026-07-01T09:00", "Europe/Berlin"),
|
||||||
"TZID=Europe/Berlin;20260722T090000",
|
"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)
|
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,6 +935,7 @@ class EventWriteMapperTest {
|
|||||||
existingExdate = "20260708T080000Z,20260715T080000Z,20260722T080000Z",
|
existingExdate = "20260708T080000Z,20260715T080000Z,20260722T080000Z",
|
||||||
beginMillis = instantAt("2026-07-15T08:00", "UTC"),
|
beginMillis = instantAt("2026-07-15T08:00", "UTC"),
|
||||||
isAllDay = false,
|
isAllDay = false,
|
||||||
|
timezone = null,
|
||||||
),
|
),
|
||||||
).isEqualTo("20260722T080000Z")
|
).isEqualTo("20260722T080000Z")
|
||||||
}
|
}
|
||||||
@@ -744,15 +943,25 @@ class EventWriteMapperTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `a split carries nothing when every exclusion is behind it`() {
|
fun `a split carries nothing when every exclusion is behind it`() {
|
||||||
assertThat(
|
assertThat(
|
||||||
exdateAfter("20260708T080000Z", instantAt("2026-07-15T08:00", "UTC"), isAllDay = false),
|
exdateAfter(
|
||||||
|
"20260708T080000Z",
|
||||||
|
instantAt("2026-07-15T08:00", "UTC"),
|
||||||
|
isAllDay = false,
|
||||||
|
timezone = null,
|
||||||
|
),
|
||||||
).isNull()
|
).isNull()
|
||||||
assertThat(exdateAfter(null, 0L, isAllDay = false)).isNull()
|
assertThat(exdateAfter(null, 0L, isAllDay = false, timezone = null)).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `all-day exclusions split on their UTC date`() {
|
fun `all-day exclusions split on their UTC date`() {
|
||||||
assertThat(
|
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")
|
).isEqualTo("20260722")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,13 +969,28 @@ class EventWriteMapperTest {
|
|||||||
fun `an unreadable exclusion carries nothing across a split`() {
|
fun `an unreadable exclusion carries nothing across a split`() {
|
||||||
assertThat(
|
assertThat(
|
||||||
exdateAfter(
|
exdateAfter(
|
||||||
"20260722T080000Z,TZID=Europe/Berlin;20260729T100000",
|
"20260722T080000Z,sometime next Tuesday",
|
||||||
instantAt("2026-07-15T08:00", "UTC"),
|
instantAt("2026-07-15T08:00", "UTC"),
|
||||||
isAllDay = false,
|
isAllDay = false,
|
||||||
|
timezone = null,
|
||||||
),
|
),
|
||||||
).isNull()
|
).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
|
@Test
|
||||||
fun `split exclusions move by the same shift as the new series start`() {
|
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
|
// What the split path composes: filter to the tail, then re-time it by the
|
||||||
@@ -777,6 +1001,7 @@ class EventWriteMapperTest {
|
|||||||
"20260716T070000Z",
|
"20260716T070000Z",
|
||||||
instantAt("2026-07-15T07:00", "UTC"),
|
instantAt("2026-07-15T07:00", "UTC"),
|
||||||
isAllDay = false,
|
isAllDay = false,
|
||||||
|
timezone = null,
|
||||||
),
|
),
|
||||||
original = original,
|
original = original,
|
||||||
updated = original.atHour(11),
|
updated = original.atHour(11),
|
||||||
@@ -988,6 +1213,7 @@ class EventWriteMapperTest {
|
|||||||
occurrenceMillis = 1_781_136_000_000L, // 2026-06-11T00:00:00Z
|
occurrenceMillis = 1_781_136_000_000L, // 2026-06-11T00:00:00Z
|
||||||
dtStartMillis = 1_749_600_000_000L,
|
dtStartMillis = 1_749_600_000_000L,
|
||||||
rrule = "FREQ=YEARLY",
|
rrule = "FREQ=YEARLY",
|
||||||
|
rdate = null,
|
||||||
duration = "P1D",
|
duration = "P1D",
|
||||||
timezone = "UTC",
|
timezone = "UTC",
|
||||||
allDay = 1,
|
allDay = 1,
|
||||||
@@ -1091,6 +1317,7 @@ class EventWriteMapperTest {
|
|||||||
"20260604T080000Z, 20260611T080000Z,20260618T080000Z",
|
"20260604T080000Z, 20260611T080000Z,20260618T080000Z",
|
||||||
1_781_164_800_000L,
|
1_781_164_800_000L,
|
||||||
isAllDay = false,
|
isAllDay = false,
|
||||||
|
timezone = null,
|
||||||
),
|
),
|
||||||
).isTrue()
|
).isTrue()
|
||||||
}
|
}
|
||||||
@@ -1099,20 +1326,22 @@ class EventWriteMapperTest {
|
|||||||
fun `an occurrence already excluded is recognised, timed and all-day`() {
|
fun `an occurrence already excluded is recognised, timed and all-day`() {
|
||||||
// Detaching twice would leave a second standalone copy.
|
// Detaching twice would leave a second standalone copy.
|
||||||
assertThat(
|
assertThat(
|
||||||
exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false),
|
exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false, timezone = null),
|
||||||
).isTrue()
|
).isTrue()
|
||||||
assertThat(
|
assertThat(
|
||||||
exdateContains("20260611", 1_781_136_000_000L, isAllDay = true),
|
exdateContains("20260611", 1_781_136_000_000L, isAllDay = true, timezone = null),
|
||||||
).isTrue()
|
).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an occurrence not in the exdate list is not mistaken for an excluded one`() {
|
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(null, 1_781_164_800_000L, isAllDay = false, timezone = null))
|
||||||
assertThat(exdateContains("", 1_781_164_800_000L, isAllDay = false)).isFalse()
|
.isFalse()
|
||||||
|
assertThat(exdateContains("", 1_781_164_800_000L, isAllDay = false, timezone = null))
|
||||||
|
.isFalse()
|
||||||
// A neighbouring occurrence must not match — the guard is per-instant.
|
// A neighbouring occurrence must not match — the guard is per-instant.
|
||||||
assertThat(
|
assertThat(
|
||||||
exdateContains("20260610T080000Z", 1_781_164_800_000L, isAllDay = false),
|
exdateContains("20260610T080000Z", 1_781_164_800_000L, isAllDay = false, timezone = null),
|
||||||
).isFalse()
|
).isFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,6 +1357,7 @@ class EventWriteMapperTest {
|
|||||||
occurrenceMillis = occurrenceMillis,
|
occurrenceMillis = occurrenceMillis,
|
||||||
dtStartMillis = 1_780_560_000_000L,
|
dtStartMillis = 1_780_560_000_000L,
|
||||||
rrule = "FREQ=WEEKLY",
|
rrule = "FREQ=WEEKLY",
|
||||||
|
rdate = null,
|
||||||
duration = "P5400S",
|
duration = "P5400S",
|
||||||
timezone = "Europe/Berlin",
|
timezone = "Europe/Berlin",
|
||||||
allDay = 0,
|
allDay = 0,
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
/** Thrown instead of [writeError] for events whose summary is in this set. */
|
/** Thrown instead of [writeError] for events whose summary is in this set. */
|
||||||
val failingImportSummaries = mutableSetOf<String>()
|
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. */
|
/** The [colorPalette] the last [insertImportedEvent] call received. */
|
||||||
var lastImportPalette: List<EventColorOption> = emptyList()
|
var lastImportPalette: List<EventColorOption> = emptyList()
|
||||||
|
|
||||||
@@ -110,7 +114,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
colorPalette: List<EventColorOption>,
|
colorPalette: List<EventColorOption>,
|
||||||
): Long {
|
): Long {
|
||||||
lastImportPalette = colorPalette
|
lastImportPalette = colorPalette
|
||||||
writeError?.let { throw it }
|
if (imports++ >= importsBeforeError) writeError?.let { throw it }
|
||||||
if (event.summary in failingImportSummaries) error("rejected: ${event.summary}")
|
if (event.summary in failingImportSummaries) error("rejected: ${event.summary}")
|
||||||
importedEvents += event to calendarId
|
importedEvents += event to calendarId
|
||||||
return nextInsertId
|
return nextInsertId
|
||||||
@@ -192,6 +196,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
override fun updateOccurrence(
|
override fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long {
|
): Long {
|
||||||
|
|||||||
@@ -148,6 +148,132 @@ class IcsExportMapperTest {
|
|||||||
assertThat(event.exDates).containsExactly("20260901", "20270901").inOrder()
|
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
|
@Test
|
||||||
fun `a one-off row exports no exclusions even if the column is set`() {
|
fun `a one-off row exports no exclusions even if the column is set`() {
|
||||||
val reader = MapColumnReader(
|
val reader = MapColumnReader(
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -322,6 +322,27 @@ class IcsFossifyImportTest {
|
|||||||
assertThat(result.events.single().calendarName).isEqualTo("Work")
|
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
|
@Test
|
||||||
fun `a multi-valued CATEGORIES is a tag list, not a calendar name`() {
|
fun `a multi-valued CATEGORIES is a tag list, not a calendar name`() {
|
||||||
val result = parser.parse(
|
val result = parser.parse(
|
||||||
|
|||||||
@@ -286,9 +286,53 @@ class RescheduleViewModelTest {
|
|||||||
vm.move(toWednesday())
|
vm.move(toWednesday())
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val abandoned = vm.abandoned.value
|
||||||
assertThat(vm.undo(undo)).isFalse()
|
assertThat(vm.undo(undo)).isFalse()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertThat(fake.updatedEvents).hasSize(1)
|
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
|
@Test
|
||||||
|
|||||||
@@ -180,4 +180,30 @@ class TimedBlockShapeTest {
|
|||||||
fun `an event outside the columns shown is sliced into nothing`() {
|
fun `an event outside the columns shown is sliced into nothing`() {
|
||||||
assertThat(dragSlices(-3 * MINUTES_PER_DAY, 60, within = 0..2)).isEmpty()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
@@ -161,4 +162,28 @@ class WidgetRolloverSchedulerTest {
|
|||||||
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
|
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
|
||||||
.contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
|
.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
fastlane/metadata/android/en-US/changelogs/21904.txt
Normal file
10
fastlane/metadata/android/en-US/changelogs/21904.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
Fixed
|
||||||
|
• An occurrence you deleted from a repeating event stays deleted — through a re-timing, a backup and a restore.
|
||||||
|
• "Only this event" now saves your edit on calendars that have never synced, and deleting one from an event that doesn't repeat deletes the event.
|
||||||
|
• A reminder left on your account's default no longer goes off as the event starts.
|
||||||
|
• The widgets turn the page at midnight instead of leaving yesterday as "today".
|
||||||
|
• An event running past midnight can be dragged by either half.
|
||||||
|
• One event the calendar refuses no longer costs you the whole import, and an import cut short tells you how far it got.
|
||||||
|
|
||||||
|
Added
|
||||||
|
• Belarusian and Slovak, from community translators on Weblate.
|
||||||
Reference in New Issue
Block a user