feat: Improve POSIX time zone string generation (#4087)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-12-28 14:53:23 -06:00 committed by GitHub
parent 869222f3e1
commit 0f00332e94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 106 additions and 75 deletions

View file

@ -19,14 +19,13 @@
package org.meshtastic.core.ui.timezone
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDateTime
import java.time.Year
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoField
import java.time.temporal.WeekFields
import java.time.zone.ZoneOffsetTransitionRule
import java.util.Locale
import kotlin.math.abs
@ -34,101 +33,117 @@ import kotlin.math.abs
* Generates a POSIX time zone string from a [ZoneId]. Uses the specification found
* [here](https://www.postgresql.org/docs/current/datetime-posix-timezone-specs.html).
*/
@Suppress("ReturnCount")
fun ZoneId.toPosixString(): String {
val now = Instant.now()
val upcomingTransition = rules.nextTransition(now)
val rules = this.rules
// No upcoming transition means this time zone does not support DST.
if (upcomingTransition == null) {
with(now.asZonedDateTime()) {
return "${timeZoneShortName()}${formattedOffsetString()}"
}
if (rules.isFixedOffset || rules.transitionRules.isEmpty()) {
val now = Instant.now()
val zdt = ZonedDateTime.ofInstant(now, this)
return "${formatAbbreviation(zdt.timeZoneShortName())}${formatPosixOffset(zdt.offset)}"
}
val upcomingInstant = upcomingTransition.instant
val followingTransition = rules.nextTransition(upcomingInstant)
val springRule = rules.transitionRules.lastOrNull { it.offsetAfter.totalSeconds > it.offsetBefore.totalSeconds }
val fallRule = rules.transitionRules.lastOrNull { it.offsetAfter.totalSeconds < it.offsetBefore.totalSeconds }
val (stdTransition, dstTransition) =
if (rules.isDaylightSavings(upcomingInstant)) {
followingTransition to upcomingTransition
} else {
upcomingTransition to followingTransition
}
val stdDate = stdTransition.instant.asZonedDateTime()
val dstDate = dstTransition.instant.asZonedDateTime()
if (springRule == null || fallRule == null) {
val now = Instant.now()
val zdt = ZonedDateTime.ofInstant(now, this)
return "${formatAbbreviation(zdt.timeZoneShortName())}${formatPosixOffset(zdt.offset)}"
}
return buildString {
append(stdDate.timeZoneShortName())
append(stdDate.formattedOffsetString())
append(dstDate.timeZoneShortName())
val stdAbbrev = getTransitionAbbreviation(this@toPosixString, fallRule)
val dstAbbrev = getTransitionAbbreviation(this@toPosixString, springRule)
append(formatAbbreviation(stdAbbrev))
append(formatPosixOffset(springRule.offsetBefore))
append(formatAbbreviation(dstAbbrev))
// Don't append the DST offset if it is only 1 hour off.
@Suppress("MagicNumber")
if (abs(stdDate.offset.totalSeconds - dstDate.offset.totalSeconds) != 3600) {
append(dstDate.formattedOffsetString())
if (springRule.offsetAfter.totalSeconds - springRule.offsetBefore.totalSeconds != 3600) {
append(formatPosixOffset(springRule.offsetAfter))
}
append(dstTransition.dateTimeBefore.transitionRuleString())
append(stdTransition.dateTimeBefore.transitionRuleString())
append(formatTransitionRule(springRule))
append(formatTransitionRule(fallRule))
}
}
/** Returns the time zone short. e.g. "EST" or "EDT". */
private fun ZonedDateTime.timeZoneShortName(): String {
internal fun ZonedDateTime.timeZoneShortName(): String {
val formatter = DateTimeFormatter.ofPattern("zzz", Locale.ENGLISH)
val shortName = format(formatter)
return if (shortName.startsWith("GMT")) "GMT" else shortName
}
/**
* Returns the time zone offset string with the format "<HOURS>:<MINUTES>:<SECONDS>". Minutes and seconds are only shown
* if they are non-zero.
*/
@Suppress("MagicNumber")
private fun ZonedDateTime.formattedOffsetString(): String {
val offsetSeconds = -offset.totalSeconds
internal fun formatAbbreviation(abbrev: String): String = if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>"
internal fun getTransitionAbbreviation(zone: ZoneId, rule: ZoneOffsetTransitionRule): String {
val transition = rule.createTransition(Year.now().value)
return ZonedDateTime.ofInstant(transition.instant, zone).timeZoneShortName()
}
@Suppress("MagicNumber")
internal fun formatPosixOffset(offset: ZoneOffset): String {
val offsetSeconds = -offset.totalSeconds
val hours = offsetSeconds / 3600
val remainingSeconds = abs(offsetSeconds) % 3600
val minutes = remainingSeconds / 60
val seconds = remainingSeconds % 60
return buildString {
if (offsetSeconds < 0 && hours == 0) append("-")
append(hours)
appendMinSec(minutes = minutes, seconds = seconds) { ":%02d".format(Locale.ENGLISH, it) }
}
}
/**
* Returns a transition rule string with the format
* ",M<MONTH>.<WEEK_OF_MONTH>.<DAY_OF_WEEK>/<HOURS>:<MINUTES>:<SECONDS>". Time is omitted if it is 2:00:00, since that
* is the spec default. Otherwise, append time with non-zero values.
*/
@Suppress("MagicNumber")
private fun LocalDateTime.transitionRuleString() = buildString {
val weekOfMonth = get(ChronoField.ALIGNED_WEEK_OF_MONTH)
val dayOfWeek = get(WeekFields.of(DayOfWeek.SUNDAY, 7).dayOfWeek()) - 1
append(",M$monthValue.$weekOfMonth.$dayOfWeek")
when {
// No-op for spec default
hour == 2 && minute == 0 && second == 0 -> Unit
else -> {
append("/$hour")
appendMinSec(minutes = minute, seconds = second) { ":$it" }
if (minutes != 0 || seconds != 0) {
append(":%02d".format(Locale.ENGLISH, minutes))
if (seconds != 0) {
append(":%02d".format(Locale.ENGLISH, seconds))
}
}
}
}
private inline fun StringBuilder.appendMinSec(minutes: Int, seconds: Int, format: (Int) -> String) {
if (minutes != 0 || seconds != 0) {
// This covers both "30m:30s" and "00m:30s"
append(format(minutes))
// This prevents "30m:00s"
if (seconds != 0) append(format(seconds))
@Suppress("MagicNumber")
internal fun formatTransitionRule(rule: ZoneOffsetTransitionRule): String {
val month = rule.month.value
val dayOfWeek = rule.dayOfWeek.value % 7
val dayIndicator = rule.dayOfMonthIndicator
val occurrence =
when {
dayIndicator < 0 -> 5
dayIndicator > rule.month.length(false) - 7 -> 5
else -> ((dayIndicator - 1) / 7) + 1
}
val wallTime =
when (rule.timeDefinition) {
ZoneOffsetTransitionRule.TimeDefinition.UTC ->
rule.localTime.plusSeconds(rule.offsetBefore.totalSeconds.toLong())
ZoneOffsetTransitionRule.TimeDefinition.STANDARD -> {
if (rule.offsetAfter.totalSeconds > rule.offsetBefore.totalSeconds) {
rule.localTime
} else {
rule.localTime.plusSeconds(
(rule.offsetBefore.totalSeconds - rule.offsetAfter.totalSeconds).toLong(),
)
}
}
else -> rule.localTime
}
return buildString {
append(",M$month.$occurrence.$dayOfWeek")
if (wallTime.hour != 2 || wallTime.minute != 0 || wallTime.second != 0) {
append("/${wallTime.hour}")
if (wallTime.minute != 0 || wallTime.second != 0) {
append(":%02d".format(Locale.ENGLISH, wallTime.minute))
if (wallTime.second != 0) {
append(":%02d".format(Locale.ENGLISH, wallTime.second))
}
}
}
}
}
context(zoneId: ZoneId)
private fun Instant.asZonedDateTime() = ZonedDateTime.ofInstant(this, zoneId)

View file

@ -20,6 +20,7 @@ package org.meshtastic.core.ui.timezone
import org.junit.Assert.assertEquals
import org.junit.Test
import java.time.ZoneId
import java.time.ZoneOffset
class ZoneIdExtensionsTest {
@ -36,20 +37,35 @@ class ZoneIdExtensionsTest {
"US/Eastern" to "EST5EDT,M3.2.0,M11.1.0",
"America/Sao_Paulo" to "BRT3",
"UTC" to "UTC0",
"Europe/London" to "GMT0BST,M3.5.0/1,M10.4.0",
"Europe/Lisbon" to "WET0WEST,M3.5.0/1,M10.4.0",
"Europe/Budapest" to "CET-1CEST,M3.5.0,M10.4.0/3",
"Europe/Kiev" to "EET-2EEST,M3.5.0/3,M10.4.0/4",
"Africa/Cairo" to "EET-2EEST,M4.4.5/0,M10.5.5/0",
"Europe/London" to "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Lisbon" to "WET0WEST,M3.5.0/1,M10.5.0",
"Europe/Budapest" to "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Kiev" to "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Africa/Cairo" to "EET-2EEST,M4.5.5/0,M10.5.5/0",
"Asia/Kolkata" to "IST-5:30",
"Asia/Hong_Kong" to "HKT-8",
"Asia/Tokyo" to "JST-9",
"Australia/Perth" to "AWST-8",
"Australia/Adelaide" to "ACST-9:30ACDT,M10.1.0,M4.1.0/3",
"Australia/Sydney" to "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Pacific/Auckland" to "NZST-12NZDT,M9.4.0,M4.1.0/3",
"Pacific/Auckland" to "NZST-12NZDT,M9.5.0,M4.1.0/3",
)
zoneMap.forEach { (tz, expected) -> assertEquals(expected, ZoneId.of(tz).toPosixString()) }
}
@Test
fun `test formatAbbreviation`() {
assertEquals("PST", formatAbbreviation("PST"))
assertEquals("<GMT-8>", formatAbbreviation("GMT-8"))
}
@Test
fun `test formatPosixOffset`() {
assertEquals("8", formatPosixOffset(ZoneOffset.ofHours(-8)))
assertEquals("-1", formatPosixOffset(ZoneOffset.ofHours(1)))
assertEquals("-5:30", formatPosixOffset(ZoneOffset.ofHoursMinutes(5, 30)))
assertEquals("0", formatPosixOffset(ZoneOffset.ofHours(0)))
assertEquals("-0:30", formatPosixOffset(ZoneOffset.ofTotalSeconds(30 * 60)))
}
}