fix(worldclock): fix Etc/GMT names at the fallback, not per caller

The previous rule named every non-place id by its current offset, which
renamed legacy ids such as Japan, called Etc/UTC "GMT" and made a CET
row's name change at DST. Only the Etc/GMT+-n ids have a misleading
last segment, so ZoneCatalog.cityFallbackFor now flips just those to
their real offset. That also reaches the zone picker's device row,
which takes its name from the same fallback.
This commit is contained in:
makiolaj committed 2026-10-22 07:44:54 +02:00
1 parent 40b4c9e2e4
commit b79b38bd2b
4 files changed
+28 -31

No files matched your search

@@ -60,24 +60,31 @@ object ZoneCatalog {
* input trimmed when that is blank. Never throws.
*/
fun cityFallbackFor(zoneId: String): String {
etcOffsetName(zoneId)?.let { return it }
val segment = zoneId.substringAfterLast('/').replace('_', ' ').trim()
return segment.ifBlank { zoneId.trim() }
}
/**
* `Etc/GMT-9` is UTC**+**9: POSIX signs these ids the other way round, so
* their last segment would name the zone with the wrong sign. A device set
* by UTC offset rather than by city (Android 13+ offers it) reports one, so
* the face and the local row name it by its real offset ("GMT+9").
*/
private fun etcOffsetName(zoneId: String): String? {
val match = ETC_OFFSET.matchEntire(zoneId.trim()) ?: return null
val hours = match.groupValues[2].toInt()
if (hours == 0) return "GMT"
val sign = if (match.groupValues[1] == "-") "+" else "-"
return "GMT$sign$hours"
}
private val ETC_OFFSET = Regex("""Etc/GMT([+-])(\d{1,2})""")
/** By `ZoneSearch.fold(city)` ascending, then [ZoneEntry.zoneId] ascending. */
fun sorted(entries: List<ZoneEntry>): List<ZoneEntry> =
entries.sortedWith(compareBy({ ZoneSearch.fold(it.city) }, { it.zoneId }))
/**
* True when [cityFallbackFor] gives a fair name for [zoneId]: a place id,
* current (`Asia/Tokyo`) or legacy (`US/Pacific`), or [UTC]. False for the
* `Etc/` offsets and the bare abbreviations tzdata also lists (`Etc/GMT-9`,
* `CET`), whose last segment is no place and, for `Etc/GMT±n`, has the sign
* reversed.
*/
fun hasPlaceName(zoneId: String): Boolean =
zoneId == UTC || ('/' in zoneId && !zoneId.startsWith("Etc/"))
private fun isRegionId(zoneId: String): Boolean =
zoneId.contains('/') && zoneId.substringBefore('/') in REGIONS
}
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.clockula.ui.worldclock
import de.jeanlucmakiola.clockula.domain.WorldClock
import de.jeanlucmakiola.clockula.domain.Zones
import de.jeanlucmakiola.clockula.domain.isKnownZone
import de.jeanlucmakiola.clockula.domain.worldclock.DayLabel
import de.jeanlucmakiola.clockula.domain.worldclock.DayNight
@@ -89,7 +88,7 @@ internal object WorldClockRows {
WorldClockRowState(
id = clock.id,
zoneId = clock.zoneId,
name = clock.label ?: cityOf(clock.zoneId, entries, now),
name = clock.label ?: cityOf(clock.zoneId, entries),
time = comparison?.localDateTime?.toLocalTime()?.truncatedTo(ChronoUnit.MINUTES),
offset = comparison?.let { ZoneOffsetFormat.labelFor(it.offsetMinutes) },
day = comparison?.let { ZoneOffsetFormat.dayLabelFor(it.dayDifference) },
@@ -133,22 +132,13 @@ internal object WorldClockRows {
val local = now.toJavaInstant().atZone(homeZone).toLocalTime().truncatedTo(ChronoUnit.SECONDS)
return HomeFaceState(
zoneId = homeZone.id,
city = cityOf(homeZone.id, entries, now),
city = cityOf(homeZone.id, entries),
zoneName = zoneNames[homeZone.id],
time = local,
dayFraction = DayNight.fractionAt(local),
)
}
/**
* A device set by UTC offset rather than by city (Android 13+ offers it)
* reports an id like `Etc/GMT-9`, whose last segment is no place and has
* its sign reversed, so such a zone is named by its real offset ("GMT+9").
*/
private fun cityOf(zoneId: String, entries: Map<String, ZoneEntry>, now: Instant): String =
if (!ZoneCatalog.hasPlaceName(zoneId) && Zones.isValid(zoneId)) {
ZoneOffsetFormat.gmtLabel(ZoneComparisons.utcOffsetMinutes(ZoneId.of(zoneId), now))
} else {
entries[zoneId]?.city ?: ZoneCatalog.cityFallbackFor(zoneId)
}
private fun cityOf(zoneId: String, entries: Map<String, ZoneEntry>): String =
entries[zoneId]?.city ?: ZoneCatalog.cityFallbackFor(zoneId)
}
@@ -226,11 +226,11 @@ class ZoneCatalogTest {
}
@Test
fun `a place id or UTC has a place name, an offset or abbreviation does not`() {
assertThat(ZoneCatalog.hasPlaceName("Asia/Tokyo")).isTrue()
assertThat(ZoneCatalog.hasPlaceName("US/Pacific")).isTrue()
assertThat(ZoneCatalog.hasPlaceName("UTC")).isTrue()
assertThat(ZoneCatalog.hasPlaceName("Etc/GMT-9")).isFalse()
assertThat(ZoneCatalog.hasPlaceName("CET")).isFalse()
fun `an Etc offset id falls back to its real offset, not its reversed sign`() {
assertThat(ZoneCatalog.cityFallbackFor("Etc/GMT-9")).isEqualTo("GMT+9")
assertThat(ZoneCatalog.cityFallbackFor("Etc/GMT+5")).isEqualTo("GMT-5")
assertThat(ZoneCatalog.cityFallbackFor("Etc/GMT+0")).isEqualTo("GMT")
assertThat(ZoneCatalog.cityFallbackFor("Etc/UTC")).isEqualTo("UTC")
assertThat(ZoneCatalog.cityFallbackFor("CET")).isEqualTo("CET")
}
}
+1 -1
View File
@@ -2407,7 +2407,7 @@ differ from it, so with no stored home the rule is always null, and Settings ›
Clock says so under the switch rather than hiding it. A device set by UTC
offset rather than by city reports an id like `Etc/GMT-9`, whose last segment
has its sign reversed, so that zone is named by its real offset ("GMT+9")
instead, on the local row and on the face alike (`ZoneCatalog.hasPlaceName`).
instead, wherever a zone falls back to its id for a name (`ZoneCatalog.cityFallbackFor`).
The row is not stored. It is `WorldClockUiState.Loaded.local`, built per tick
by the source from the same `zones.current()` read the face uses, so a