Skip to content

Set TZ to the Olson time zone ID on Android, not its abbreviation - #43

Open
paulober wants to merge 1 commit into
skiptools:mainfrom
paulober:fix/android-timezone-olson-id
Open

paulober wants to merge 1 commit into
skiptools:mainfrom
paulober:fix/android-timezone-olson-id

Conversation

@paulober

@paulober paulober commented Oct 15, 2026 •

Copy link
Copy Markdown

bootstrapTimezone() sets TZ from localtime_r(...).tm_zone, which is only the time zone abbreviation ("CEST" on a Europe/Berlin device). bionic reads a POSIX TZ string without an offset as UTC, so once the bootstrap has run:

  • libc's localtime/mktime run in UTC
  • a DateFormatter without an explicit timeZone formats and parses in UTC
  • TimeZone.current resolves the abbreviation through Foundation's abbreviation table, so it gets another zone that happens to share the offset (Europe/Paris instead of Europe/Berlin)

In our app (Skip Fuse, native) every "x hours ago" was off by 2 hours, because the API's local timestamps were parsed in UTC.

This PR reads the device's Olson ID from the persist.sys.timezone system property, which is what bionic itself uses when TZ is unset. It sets TZ only if it isn't set already. With the Olson ID, Foundation, libc and ICU all agree.

How I checked it: I built a small native Swift executable with the Swift 6.4 Android SDK and ran it on an API 36 emulator set to Europe/Berlin. It's a separate test program, not the bridge's test suite. Each run is a fresh process, because TimeZone.current is cached:

TZ set by TimeZone.current libc localtime DateFormatter()
nothing (no bootstrap) GMT, offset 0 CEST +7200 22:59 GMT+2
current bootstrapTimezone() → TZ=CEST Europe/Paris, +7200 UTC, 0 20:59 UTC
this PR → TZ=Europe/Berlin Europe/Berlin, +7200 CEST +7200 22:59 GMT+2

SKIP_DYNAMIC_LIBRARIES=1 swift build and skip android build both succeed. The Android libSkipAndroidBridge.so links __system_property_get@LIBC.

Notes:

  • PROP_VALUE_MAX (92) isn't importable as a Swift constant from the Android module, so the buffer size is a literal with a comment. __system_property_get itself is visible without extra imports.
  • The function is now behind #if os(Android). Its only call site already was.
  • Not addressed here: if the user changes the time zone while the app is running, TZ stays at the value it had at launch (same as before this PR). The Kotlin side would need to listen for ACTION_TIMEZONE_CHANGED, then update TZ, call tzset() and NSTimeZone.resetSystemTimeZone(). A real fix belongs in swift-foundation: TimeZone_Cache.findCurrentTimeZone() could read persist.sys.timezone on Android before falling back to tm_zone, which isn't a valid identifier there either.
  • Apps can work around this today by setting TZ themselves before ProcessInfo.launch in Main.kt; the bootstrap uses overwrite = 0, so their value wins: android.system.Os.setenv("TZ", java.util.TimeZone.getDefault().id, true).

bootstrapTimezone() set TZ from localtime_r(...).tm_zone, which is only
the abbreviation (e.g. "CEST"). As a POSIX TZ string without an offset
that means UTC to bionic, so after the bootstrap libc's localtime and a
DateFormatter without an explicit timeZone ran in UTC, while
TimeZone.current mapped the abbreviation to some other zone with the same
offset (Europe/Paris for a Europe/Berlin device).

Read the system time zone from the persist.sys.timezone property (what
bionic itself uses when TZ is unset) and only set TZ when it isn't set
already.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant