diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt index b72ad6a..cfdbac1 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt @@ -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): List = 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 } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt index fd1411a..d54e877 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt @@ -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, 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 = + entries[zoneId]?.city ?: ZoneCatalog.cityFallbackFor(zoneId) } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt index 4843e01..9591787 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt @@ -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") } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 007bf77..46682c3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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