Meshtastic-Android icon indicating copy to clipboard operation
Meshtastic-Android copied to clipboard

[Bug]: Time Zone POSIX String generation is wrong

Open skgsergio opened this issue 3 months ago • 0 comments

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

  1. Live in Europe
  2. Go to Settings
  3. Open Device
  4. 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!")
    }
}

skgsergio avatar Nov 14 '25 21:11 skgsergio