From e7a8aea56cd0243717b62d20f43d9281a5672eb3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 16 Oct 2026 13:22:44 +0200 Subject: [PATCH] Add zoneOrNull for the repeated TimeZone.of guards --- .../calendula/domain/EventForm.kt | 10 ++-- .../calendula/domain/EventShift.kt | 5 +- .../calendula/domain/ics/BasicFormat.kt | 47 +++++++++++++++++++ .../calendula/domain/ics/IcsParser.kt | 7 +-- .../calendula/domain/ics/IcsWriter.kt | 3 +- .../calendula/ui/detail/EventDetailScreen.kt | 3 +- 6 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/BasicFormat.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index bb00bbc..4ffe8b5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -148,16 +148,17 @@ enum class EventFormProblem { fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm { // All-day events are date-anchored and carry a nominal "UTC" that is an // anchor, not a location, so they never pin a zone. - val pinnedZone = if (instance.isAllDay) { + val pinned = if (instance.isAllDay) { null } else { eventTimezone ?.takeIf { it != zone.id } // An unparseable id (a malformed sync row) can't be honoured or // shown; fall back to the device zone rather than failing the open. - ?.takeIf { runCatching { TimeZone.of(it) }.isSuccess } + ?.let { id -> zoneOrNull(id)?.let { id to it } } } - val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone + val pinnedZone = pinned?.first + val formZone = pinned?.second ?: zone val begin = Instant.fromEpochMilliseconds(beginMillis) val finish = Instant.fromEpochMilliseconds(endMillis) val (start, end) = if (instance.isAllDay) { @@ -243,8 +244,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon */ fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean { if (isAllDay) return false - val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return false - return pinned.id != target.id + return resolvedZone(target).id != target.id } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt index 9d8ea25..b77b8af 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt @@ -17,12 +17,15 @@ import kotlin.time.Instant import kotlin.time.toJavaInstant import kotlin.time.toKotlinInstant +/** [id] as a zone, or null when the tz database doesn't know it. */ +internal fun zoneOrNull(id: String): TimeZone? = runCatching { TimeZone.of(id) }.getOrNull() + /** * The zone this form's wall-clock times mean, matching what the data layer * resolves them in at write time: the form's own pinned zone, else [deviceZone]. */ fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone = - timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone + timezone?.let(::zoneOrNull) ?: deviceZone /** * This wall-clock time in [zone], at [preferred] when the fall-back hour repeats diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/BasicFormat.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/BasicFormat.kt new file mode 100644 index 0000000..324a153 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/BasicFormat.kt @@ -0,0 +1,47 @@ +package de.jeanlucmakiola.calendula.domain.ics + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.format.char +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant + +/** RFC 5545 basic format, `yyyyMMddTHHmmss`. */ +private val BASIC_DATE_TIME = LocalDateTime.Format { + date(LocalDate.Formats.ISO_BASIC) + char('T') + hour() + minute() + second() +} + +/** `yyyyMMdd`. */ +internal fun LocalDate.toBasic(): String = format(LocalDate.Formats.ISO_BASIC) + +/** `yyyyMMddTHHmmss`, floating. */ +internal fun LocalDateTime.toBasic(): String = format(BASIC_DATE_TIME) + +/** `yyyyMMddTHHmmssZ`, the instant in UTC. */ +internal fun Instant.toUtcBasic(): String = toLocalDateTime(TimeZone.UTC).toBasic() + "Z" + +/** + * The date in the first eight characters of a basic-format value; whatever + * follows (a time, a `Z`) is ignored. Null when those aren't a valid date. + */ +internal fun parseBasicDate(s: String): LocalDate? = runCatching { + LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt()) +}.getOrNull() + +/** `yyyyMMddTHHmm[ss]`; the separator isn't checked and missing seconds read as zero. */ +internal fun parseBasicDateTime(s: String): LocalDateTime? = runCatching { + val date = parseBasicDate(s) ?: return null + val time = LocalTime( + s.substring(9, 11).toInt(), + s.substring(11, 13).toInt(), + if (s.length >= 15) s.substring(13, 15).toInt() else 0, + ) + LocalDateTime(date, time) +}.getOrNull() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt index 0c95821..83cc35a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.domain.ics import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.EventStatus +import de.jeanlucmakiola.calendula.domain.zoneOrNull import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime @@ -343,7 +344,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault */ private fun normalizeExDates(lines: List, start: IcsDateTime): List { if (lines.isEmpty()) return emptyList() - val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone + val startZone = zoneOrNull(start.zoneId) ?: deviceZone val startTime = start.instant.toLocalDateTime(startZone).time return lines .flatMap { line -> line.value.split(',').map { line to it.trim() } } @@ -373,7 +374,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null val zone = when { token.endsWith("Z") -> TimeZone.UTC - else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() } + else -> line.params["TZID"]?.let(::zoneOrNull) ?: startZone } return ldt.toInstant(zone) @@ -409,7 +410,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC") val tzid = line.params["TZID"] - val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } } + val resolved = tzid?.let { id -> zoneOrNull(id)?.let { id to it } } if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone) val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone) return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt index c26dd9c..40192cd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.domain.ics import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.EventStatus +import de.jeanlucmakiola.calendula.domain.zoneOrNull import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.number @@ -68,7 +69,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) { } // Recurring: anchor to wall-clock in the event's own zone. event.recurrenceRule?.isNotBlank() == true -> { - val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull() + val zone = zoneOrNull(event.zoneId) if (zone != null) { add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}") add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 1e26b19..a202907 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -89,6 +89,7 @@ import de.jeanlucmakiola.calendula.domain.RichText import de.jeanlucmakiola.calendula.domain.TimeZoneOption import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.zoneDescriptor +import de.jeanlucmakiola.calendula.domain.zoneOrNull import de.jeanlucmakiola.floret.components.IconInfoCard import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme import de.jeanlucmakiola.floret.identity.predictiveBack @@ -420,7 +421,7 @@ private fun EventDetailContent( // — which a cross-zone event easily does — carries the whole span // in the primary instead, so fall back to it. If the zone can't be // read back at all, drop to just the abbreviation + offset. - val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull() + val originalTime = zoneOrNull(zoneOption.id) ?.let { rememberWhen(instance, it, locale) } ?.let { (primary, secondary) -> secondary ?: primary } Spacer(Modifier.height(2.dp))