[Bug]: Time Zone POSIX String generation is wrong
Contact Details
skgsergio on discord
Checklist
-
[x] I am able to reproduce the bug with the latest version.
-
[x] I made sure that there are no existing OPEN or CLOSED issues which I could contribute my information to.
-
[x] I have taken the time to fill in all the required details. I understand that the bug report will be dismissed otherwise.
-
[x] This issue contains only one bug.
-
[x] I have read and understood the Contribution Guidelines.
-
[x] I agree to follow this project's Code of Conduct
Affected app version
2.7.6 (29319372) google
Affected Android version
Android 15
Affected phone model
OnePlus Nord 4
Affected node model
Any
Affected node firmware version
Any
Steps to reproduce the bug
- Live in Europe
- Go to Settings
- Open Device
- Click "Use phone timezone"
Actual behavior
It generates GMT-1GMT,M3.5.0,M10.4.0/3
Expected behavior
It should generate the correct POSIX TZ string, which is: CET-1CEST,M3.5.0,M10.5.0/3
The GMT-1GMT portion is not technically correct, but it is functionally equivalent to CET-1CEST, so that part is less critical.
The real issue is the DST to STD transition rule: the app currently outputs the 4th week of October (M10.4.0), but the correct rule is the last week (M10.5.0). Because of this, the generated TZ string will be wrong in years where October has five Sundays. For example, in 2027 the current rule will fail.
The bug happens because the code determines the "week number" based only on the next transition, instead of using the actual java.time.zone.ZoneOffsetTransitionRule rule. This coincidentally works for the immediate next change but does not reflect the real recurring rule.
You can validate it using against the zoneinfo database:
$ tail -1 /usr/share/zoneinfo/Europe/Madrid
CET-1CEST,M3.5.0,M10.5.0/3
$ tail -1 /usr/share/zoneinfo/Europe/Berlin
CET-1CEST,M3.5.0,M10.5.0/3
$ tail -1 /usr/share/zoneinfo/Europe/London
GMT0BST,M3.5.0/1,M10.5.0
Screenshots/Screen recordings
No response
Relevant log output
Additional information
I’m familiar with POSIX TZ strings but not with Kotlin, so I experimented a bit with AI. The following approach seems to produce correct results. The examples used are taken directly from the relevant zoneinfo files.
The code is probably not high quality, but it does capture the essential logic needed to compute the correct POSIX TZ string from the java.time.zone.ZoneOffsetTransitionRule rules.
Code inside
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.Instant
import java.time.format.DateTimeFormatter
/**
* Extension function to convert a ZoneId to a POSIX timezone specification string.
*
* POSIX format: STD offset [DST [dstoffset] [, rule]]
*/
fun ZoneId.toPosixString(): String {
val rules = this.rules
// Handle zones without DST
if (rules.isFixedOffset || rules.transitionRules.size < 2) {
val offset = rules.getOffset(Instant.now())
return "${formatAbbreviation(getCurrentAbbreviation())}${formatPosixOffset(offset)}"
}
// Identify spring (to DST) and fall (to STD) transitions
val springRule = rules.transitionRules.firstOrNull { it.offsetAfter.totalSeconds > it.offsetBefore.totalSeconds }
?: return buildNoDstString(rules)
val fallRule = rules.transitionRules.firstOrNull { it.offsetAfter.totalSeconds < it.offsetBefore.totalSeconds }
?: return buildNoDstString(rules)
// Build POSIX string
return buildString {
append(formatAbbreviation(getTransitionAbbreviation(this@toPosixString, fallRule)))
append(formatPosixOffset(springRule.offsetBefore))
append(formatAbbreviation(getTransitionAbbreviation(this@toPosixString, springRule)))
// Omit DST offset if it's exactly 1 hour ahead of standard time
if (springRule.offsetAfter.totalSeconds - springRule.offsetBefore.totalSeconds != 3600) {
append(formatPosixOffset(springRule.offsetAfter))
}
append(",", formatTransitionRule(springRule), ",", formatTransitionRule(fallRule))
}
}
private fun ZoneId.buildNoDstString(rules: java.time.zone.ZoneRules) =
"${formatAbbreviation(getCurrentAbbreviation())}${formatPosixOffset(rules.getOffset(Instant.now()))}"
private fun getTransitionAbbreviation(zone: ZoneId, rule: java.time.zone.ZoneOffsetTransitionRule) =
rule.createTransition(java.time.Year.now().value).instant.atZone(zone)
.format(DateTimeFormatter.ofPattern("zzz"))
private fun ZoneId.getCurrentAbbreviation() =
Instant.now().atZone(this).format(DateTimeFormatter.ofPattern("zzz"))
private fun formatPosixOffset(offset: ZoneOffset): String {
val posixSeconds = -offset.totalSeconds
val hours = posixSeconds / 3600
val minutes = (kotlin.math.abs(posixSeconds) % 3600) / 60
val seconds = kotlin.math.abs(posixSeconds) % 60
return buildString {
if (posixSeconds < 0) append("-")
append(kotlin.math.abs(hours))
if (minutes != 0 || seconds != 0) {
append(":%02d".format(minutes))
if (seconds != 0) append(":%02d".format(seconds))
}
}
}
private fun formatAbbreviation(abbrev: String) =
if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>"
private fun formatTransitionRule(rule: java.time.zone.ZoneOffsetTransitionRule): String {
val month = rule.month.value
val dayOfWeek = rule.dayOfWeek.value % 7 // Sunday = 0 in POSIX
val dayIndicator = rule.dayOfMonthIndicator
// Determine occurrence: negative or > (daysInMonth - 7) means "last"
val occurrence = when {
dayIndicator < 0 -> 5
dayIndicator > rule.month.length(false) - 7 -> 5
else -> ((dayIndicator - 1) / 7) + 1
}
// Convert time to wall time based on TimeDefinition
val wallTime = when (rule.timeDefinition.toString()) {
"UTC" -> rule.localTime.plusSeconds(rule.offsetBefore.totalSeconds.toLong())
"STANDARD" -> {
if (rule.offsetAfter.totalSeconds > rule.offsetBefore.totalSeconds) {
rule.localTime // Spring: use as-is
} else {
// Fall: add DST offset difference
rule.localTime.plusSeconds((rule.offsetBefore.totalSeconds - rule.offsetAfter.totalSeconds).toLong())
}
}
else -> rule.localTime // WALL time
}
val timeStr = if (wallTime.hour != 2 || wallTime.minute != 0) {
if (wallTime.minute != 0) "/${wallTime.hour}:${"%02d".format(wallTime.minute)}" else "/${wallTime.hour}"
} else ""
return "M$month.$occurrence.$dayOfWeek$timeStr"
}
// Validation test
fun main(args: Array<String>) {
val testZones = mapOf(
"America/New_York" to "EST5EDT,M3.2.0,M11.1.0",
"Europe/Paris" to "CET-1CEST,M3.5.0,M10.5.0/3",
"America/Los_Angeles" to "PST8PDT,M3.2.0,M11.1.0",
"Europe/London" to "GMT0BST,M3.5.0/1,M10.5.0",
"Australia/Sydney" to "AEST-10AEDT,M10.1.0,M4.1.0/3",
"America/Chicago" to "CST6CDT,M3.2.0,M11.1.0",
"Europe/Berlin" to "CET-1CEST,M3.5.0,M10.5.0/3",
"America/Denver" to "MST7MDT,M3.2.0,M11.1.0",
"Europe/Madrid" to "CET-1CEST,M3.5.0,M10.5.0/3",
"UTC" to "UTC0",
"Asia/Tokyo" to "JST-9"
)
println("POSIX Timezone String Validation")
println("=".repeat(70))
println()
var matches = 0
testZones.forEach { (zoneName, expected) ->
val actual = ZoneId.of(zoneName).toPosixString()
val match = expected == actual
if (match) {
matches++
println("✓ %-25s %s".format(zoneName, actual))
} else {
println("✗ %-25s".format(zoneName))
println(" Expected: $expected")
println(" Got: $actual")
}
}
println()
println("=".repeat(70))
println("Results: $matches/${testZones.size} matches (${matches * 100 / testZones.size}%)")
if (matches == testZones.size) {
println("SUCCESS: All generated POSIX strings match system zoneinfo data!")
}
}