local_timezone 1.0.0
local_timezone: ^1.0.0 copied to clipboard
Synchronously read the device's IANA timezone identifier, such as Asia/Kolkata, in pure Dart. Works on Android, iOS, macOS, Windows, Linux and the web, with no plugin and no dependencies.
local_timezone #
Read the device's IANA timezone identifier, such as Asia/Kolkata, synchronously,
in pure Dart, on Android, iOS, macOS, Windows, Linux and the web.
final zone = LocalTimezone.getTimeZoneName(); // Asia/Kolkata
No plugin, no platform channel, no native code. Because it is
synchronous, it can be called from build() without an await.
Install #
dart pub add local_timezone
import 'package:local_timezone/local_timezone.dart';
There is nothing else to do. No permissions, no manifest entries, no entitlements, and nothing to register.
Writing a Flutter app? Depend on
flutter_local_timezoneinstead. It re-exports everything here, so reading the zone is identical, and it addsLocalTimezoneWatcher, which tells you when the device's timezone changes. Change notification needs each platform's own notification API, which needs native code, which needs a plugin, so it cannot live in this package. There is no reason to depend on both.
Platforms #
All six are implemented and tested on CI: Linux, macOS and Windows on x64 and
arm64, Chrome on both dart2js and dart2wasm, Node, an Android emulator and
an iOS simulator, each crossed with several system timezones. Each provider's
resolution logic is also written as pure functions over its inputs, so the
shapes a platform can report are covered by unit tests off the platform itself.
Android #
Reads persist.sys.timezone through bionic's __system_property_get, which is a
shared-memory read rather than a syscall. It is in libc's base ABI and has been since
API 1, and the property since API 4, so there is effectively no version floor.
The property normally holds an IANA identifier, though not necessarily a current one:
an emulator set to Asia/Calcutta stores exactly that, and canonicalized reports
Asia/Kolkata. It can also hold a java.util.TimeZone custom ID such as GMT+05:30,
which bionic documents as happening on set-top boxes that take the zone from the TV
network and write it straight to the property. Those are returned as an
OffsetLocalTimezone.
The sign is not inverted. POSIX reads GMT+5 as five hours behind UTC and Java
reads it as five hours ahead, and this property is the Java one. bionic says so
directly: "Since (a) Java is the one that matches human expectations and (b) this
system property is used directly by Java, we flip the sign here to translate from Java
to POSIX." That flip belongs to bionic's own C consumers, and a caller reading the
property must not repeat it.
For the same reason the offset is parsed rather than taken from
DateTime.now().timeZoneOffset, which is the opposite of what the web path does.
bionic's flip is guarded by strcmp(buf, "GMT"), true only when the value is exactly
GMT, so for GMT+05:30 it never runs and bionic's own localtime reads the string as
POSIX. On such a device the VM clock and the Java clock disagree by twice the offset,
and the Java reading is what the rest of the system shows the user.
Apple #
Sends [[NSTimeZone localTimeZone] name] through objc_msgSend. That is the exact
counterpart of TimeZone.autoupdatingCurrent.identifier in a native Swift app:
Foundation bridges +localTimeZone through _autoupdating(), and -name is the same
string Swift calls .identifier. Both resolve from TZFILE, then TZ, then the
TZDEFAULT symlink, which is /etc/localtime on macOS and the simulator and
/var/db/timezone/localtime on a device.
Re-sending the message on every call is what keeps the value current, not the
auto-updating proxy: +localTimeZone and +systemTimeZone read the same memoized
cache slot, and the proxy only matters to a caller holding the object. What
+localTimeZone does buy is that a host app calling +setDefaultTimeZone: cannot move
the answer.
| Foundation reports | Result |
|---|---|
Asia/Calcutta (a deprecated alias) |
NamedLocalTimezone('Asia/Kolkata') |
GMT (Foundation's name for UTC) |
NamedLocalTimezone('Etc/GMT') |
GMT+0530, from a POSIX TZ |
OffsetLocalTimezone |
| nil anywhere in the message chain | LocalTimezoneUnavailableException |
Foundation canonicalizes nothing, so canonicalization is load-bearing here rather than the no-op it looks like on a Mac already set to a primary name. See Canonicalization.
The offset comes from the runtime, not from Foundation's name. The two disagree on
a POSIX TZ: given TZ=GMT+5, Foundation builds a zone it calls GMT+0500 while libc
runs the process at UTC-05:00, and given TZ=GMT+0530 Foundation says +05:30 while the
process is in UTC. Dart's DateTime follows libc, so reporting Foundation's number would
leave the package disagreeing with every DateTime in the same program. raw keeps
Foundation's spelling, offset is what the runtime applies, and the mismatch between
them is deliberate.
A configured device never reaches this branch. System Settings offers cities, not
offsets, so it always yields a real IANA zone, and for those Foundation and libc agree.
It is a CLI, container and server case. Note also that Foundation honours TZ rather
than falling back to the system zone, so a process with TZ=Europe/Paris reports Paris
on a Mac set to Kolkata, exactly as DateTime does.
A long-lived process with no runloop can go stale. Foundation drops its cached zone
on NSSystemTimeZoneDidChangeNotification, which Apple posts on the main queue. Flutter
services that queue; a plain Dart CLI or server does not, so a change may never be
observed. Short-lived processes are unaffected.
Linux #
Reads TZ, then the /etc/localtime symlink, then /etc/timezone. The first two are
what glibc itself consults, in that order. The third is not: it is a Debian convention
no libc reads, checked last only because it is the remaining place a zone name can be
recovered from. No FFI is involved, just dart:io.
| Source | Result |
|---|---|
TZ=Asia/Kolkata, :Asia/Kolkata, or a path into the database |
NamedLocalTimezone |
TZ=EST5EDT,M3.2.0,M11.1.0, a POSIX rule naming no zone |
OffsetLocalTimezone |
/etc/localtime symlinked into the database |
NamedLocalTimezone |
/etc/localtime copied rather than linked, no /etc/timezone |
OffsetLocalTimezone |
| none of the above | LocalTimezoneUnavailableException |
There is no kernel or libc version floor. The requirement is a distribution providing
one of those sources, which every mainstream one does. Minimal images often do not: a
stock ubuntu image ships no zoneinfo at all and distroless ships a dangling symlink,
so both need TZ set.
Two details that are easy to get wrong and are covered by tests. Links into the
posix/ and right/ subtrees, which most distributions ship, must have that component
stripped or the result is right/Asia/Kolkata, which resolves nowhere. And Etc/GMT+5
is a zone while EST5EDT is a rule, despite both containing a digit, so the
discrimination is anchored on an abbreviation immediately followed by an offset.
Windows #
Calls GetDynamicTimeZoneInformation and maps the registry key it reports through
CLDR. Only kernel32.dll is opened, which is the point: the obvious alternative,
ICU's ucal_getTimeZoneIDForWindowsID, would impose a Windows 10 version 1903 floor,
because that is when the combined icu.dll first shipped. This works back to Vista.
The older GetTimeZoneInformation is not used because it reports a localized display
name that cannot be looked up, and is less accurate besides: in Singapore it says
"Malay Peninsula Standard Time" where the registry key is "Singapore Standard Time".
The mapping is not one-to-one, and treating it as one relocates people. Of 139
Windows keys, 66 cover more than one IANA zone. "Romance Standard Time" is
Europe/Paris in France and Europe/Madrid in Spain; CLDR marks the tie-break with
territory 001. Consulting only that fallback, which is what most implementations do,
puts every Spanish user in Paris. So GetUserDefaultGeoName supplies the region and a
territory table refines the answer. That function arrived in Windows 10 version 1709 and
is resolved at runtime, so older Windows loses the refinement rather than failing to
start.
Canonicalization is load-bearing here too. CLDR freezes its canonical IDs for
stability, so 115 of the 361 zones it names are deprecated spellings: "India Standard
Time" maps to Asia/Calcutta, not Asia/Kolkata. Returning those unrewritten would
fail downstream for roughly a third of Windows configurations.
Web #
Both dart2js and dart2wasm are supported and tested. The lookup is
Intl.DateTimeFormat().resolvedOptions().timeZone, which is synchronous on both, so
the web path needs no await either. Only dart:js_interop is used, and Intl is not
a DOM API, so this also runs in a worker.
Browsers report more shapes than an identifier, and each is handled:
| The engine reports | Result |
|---|---|
Asia/Calcutta (a deprecated alias) |
NamedLocalTimezone('Asia/Kolkata') |
+05:30, or GMT+05:00 on Safari |
OffsetLocalTimezone |
Etc/Unknown |
LocalTimezoneUnavailableException |
undefined or an empty string |
LocalTimezoneUnavailableException |
The offset is taken from the engine, never parsed from the string. V8 misreports the
sign when the host TZ holds a POSIX value: TZ=GMT+5 yields the identifier +05:00
while correctly applying UTC-05:00. Parsing that string would put a caller ten hours out,
so OffsetLocalTimezone.offset comes from the offset the engine actually applies, with
the reported string kept in raw.
Etc/GMT+5 is an identifier rather than an offset, despite how it looks, and is treated
as a name.
Canonicalization #
Platforms disagree about which spelling to report for the same zone. Chrome and Node
say Asia/Calcutta where Firefox and Linux say Asia/Kolkata, and the same split
exists for Kyiv, Ho Chi Minh City, Yangon, Nuuk, Kanton, Kathmandu and Buenos Aires.
By default the identifier is rewritten to the current primary name, so one device
yields one answer whatever it is running:
LocalTimezone.getTimeZoneName(); // Asia/Kolkata, the canonical spelling
switch (LocalTimezone.getTimeZone()) {
case NamedLocalTimezone(:final raw, :final name, :final canonicalized):
raw; // India Standard Time <- what the platform said
name; // Asia/Calcutta <- parsed, not corrected
canonicalized; // Asia/Kolkata <- the primary spelling
case OffsetLocalTimezone():
// no identifier to canonicalize
}
All three layers are always present; there is nothing to opt into. On Apple,
Android and the web raw and name are the same string, because those platforms
already report an identifier. Windows and Linux are where they differ, reporting a
registry key and a filesystem path respectively.
This also matters downstream. package:timezone's default dataset,
data/latest.dart, is generated from the Zone entries of tzdb's rearguard.zi, so
it holds primary names only and rejects every alias. tz.getLocation('Asia/Calcutta')
throws, which means the obvious
tz.getLocation(LocalTimezone.getTimeZoneName()) fails for every Indian user on
Chrome unless the canonical spelling is used.
Every result is a primary zone name. The table is taken verbatim from the two tzdb
files that declare Link entries, and a Link always points at a Zone. So the set of
values this can return is exactly the set package:timezone accepts by default. A test
asserts that rather than assuming it.
Apple is not on the primary-name side of that split, despite appearances. Foundation
does no canonicalization whatsoever: the identifier is the tail of the TZDEFAULT
symlink, returned verbatim, so an iOS or macOS device reports whichever spelling the OS
wrote. Asia/Saigon and Europe/Kiev both turn up in the wild. Apple's own
knownTimeZoneNames cannot arbitrate either, since ICU's stable-ID policy keeps
Asia/Calcutta canonical and omits Asia/Kolkata altogether: validating against that
list would reject values the same API returns.
Compatibility with package:timezone #
Verified against timezone 0.11.1. The 257 aliases resolve to 111 distinct
identifiers, and all 111 are accepted by all three of its datasets:
| Dataset | Accepted |
|---|---|
data/latest.dart (the default) |
111 / 111 |
data/latest_all.dart |
111 / 111 |
data/latest_10y.dart |
111 / 111 |
So this composes directly, with no lookup that can throw on an alias:
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:timezone/timezone.dart' as tz;
tzdata.initializeTimeZones();
tz.setLocalLocation(tz.getLocation(LocalTimezone.getTimeZoneName()));
Using name rather than canonicalized there would throw on data/latest.dart for anyone whose platform
reports an alias, which includes every Chrome user in India, Ukraine, Vietnam, Myanmar,
Greenland, Nepal and Argentina, plus every machine set to UTC on any platform.
This is structural rather than a lucky spot check. data/latest.dart is generated from
the Zone entries of tzdb's rearguard.zi, and every value this package can return is
one of those Zone entries by construction.
The one way it could drift is a tzdb release that adds a zone: regenerating this table
against a newer release than timezone ships could name a zone its dataset lacks.
Nothing like that is outstanding today, and data/latest_all.dart is immune either way.
The table is generated by
tool/generate_backward.dart, currently tzdb 2026c,
257 entries, about 10 KB. Nothing is validated: an identifier this package has not
heard of passes straight through, so a stale table degrades to "no rewrite" rather
than to a wrong answer.
Sources. Aliases come from
backwardandetcetera, which between them hold everyLinkin the database; the seven regional files declare none. The release is read fromversion. All are fetched at generation time only. Nothing is downloaded at runtime and the package has no network dependency. Regenerate withdart run tool/generate_backward.dartafter a new tzdb release.
One thing is deliberately never rewritten. Etc/GMT+5 and friends are left alone.
They look like aliases but are real zones with an inverted sign, so Etc/GMT+5 is
UTC-05:00 by the database's own design. They are Zone entries rather than Link
entries and so never enter the table. Rewriting one would move a user by twice their
offset.
The zero-offset family needs no special case. UTC, Zulu, Universal and UCT
resolve to Etc/UTC; GMT, GMT0 and Greenwich resolve to Etc/GMT. Browsers
report UTC and Apple reports GMT, so both get rewritten, and both would otherwise
be rejected downstream.
Why #
DateTime.now().timeZoneName returns an abbreviation, not an identifier. It gives
you IST, which is India, Ireland, and Israel, and it gives you different kinds
of value depending on where you run it:
| Native | Web | |
|---|---|---|
DateTime.now().timeZoneName |
IST |
India Standard Time |
LocalTimezone.getTimeZoneName() |
Asia/Kolkata |
Asia/Kolkata |
Existing packages solve this with a platform channel, which forces the answer to be asynchronous even though every underlying OS call is synchronous.
Usage #
import 'package:local_timezone/local_timezone.dart';
// The common case: an identifier, in the spelling a timezone database accepts.
final zone = LocalTimezone.getTimeZoneName(); // Asia/Kolkata
// A device can report a fixed UTC offset instead of a zone. The result is
// sealed, so both shapes are handled without an exception.
switch (LocalTimezone.getTimeZone()) {
case NamedLocalTimezone(:final canonicalized): useZone(canonicalized);
case OffsetLocalTimezone(:final offset): useOffset(offset);
}
Failures are exceptions, never a silent fallback to UTC, because a quietly wrong
timezone is harder to notice than a crash and tends to surface as corrupted
timestamps much later. LocalTimezoneException is sealed, so they can be handled
exhaustively:
try {
useZone(LocalTimezone.getTimeZoneName());
} on LocalTimezoneException catch (error) {
switch (error) {
case LocalTimezoneUnavailableException(:final platform, :final reason):
log('no timezone on $platform: $reason');
case LocalTimezoneNotNamedException(:final resolved):
// The name-returning form was asked for a name and the device only has an
// offset. The result is attached, so no second lookup is needed.
useOffset(resolved.offset);
}
}
Nothing is cached, so every call reflects the device's current timezone even if the user changes it mid-session. A call costs a few hundred nanoseconds on Android and Apple and about 40 microseconds on Linux and the web, which is several hundred per frame at the slowest. Memoize at the call site if that is not enough, since that is also the only place that knows when the value should be discarded.
getTimeZoneAsync() and getTimeZoneNameAsync() mirror both entry points for
callers already inside async code, or migrating from a channel-based package.
See example/local_timezone_example.dart
for the full set of cases as runnable code.
Requirements #
The Dart SDK constraint is ^3.12.0, and CI runs a job pinned to exactly that
floor rather than to whatever stable happens to be.
Two different floors matter. The first column is what this package's own code needs. The second is what Flutter imposes on any app regardless, and for Flutter users it is the one that binds. In a plain Dart CLI or server app, only the first column applies.
| Platform | This package needs | Flutter 3.44 needs |
|---|---|---|
| Android | API 1 | API 24 |
| iOS | iOS 11.0 | iOS 13.0 |
| macOS | macOS 10.13 | macOS 10.15 |
| Windows | Windows Vista / Server 2008 | Windows 10 |
| Linux | no version floor | Debian 10+, Ubuntu 20.04+ |
Web, dart2js |
Chrome 45, Edge 14, Firefox 53, Safari 10 | Safari 15.6+, latest 2 of the rest |
Web, dart2wasm |
Chrome 119, Edge 119, Firefox 120, Safari 18.2 | Chromium only, see below |
Notes on the numbers #
iOS 11 and macOS 10.13 are a correctness floor, not an availability floor. Every
Objective-C symbol used here has existed since macOS 10.0 and iOS 2.0. The constraint
comes from Apple's own documentation of NSTimeZone.local: "In macOS High Sierra and
later, iOS 11 and later, tvOS 11 and later, and watchOS 4 and later, the local class
property reflects the current system time zone, whereas previously it reflected the
default time zone." Below those versions the API still works, but can report the app's
default zone rather than the device's.
Android's floor is effectively zero. __system_property_get has been in bionic's
base ABI since API 1, and persist.sys.timezone since API 4. The package ships no
.so of its own, so the 16 KB page size requirement introduced for API 35 does not
apply to it.
Windows needs no ICU. GetDynamicTimeZoneInformation has been in kernel32.dll
since Vista, and the Windows-to-IANA mapping is a bundled CLDR table. Packages that
call ucal_getTimeZoneIDForWindowsID instead inherit a Windows 10 version 1903 floor,
because that is when the combined icu.dll first shipped. This package does not.
Linux has no version floor, but it does have a configuration requirement. The
lookup reads TZ, then resolves the /etc/localtime symlink. Mainstream distributions
provide that symlink. Minimal container images frequently do not: a stock ubuntu base
image ships no zoneinfo at all, and distroless ships a dangling link. On those, set
TZ or expect LocalTimezoneUnavailableException.
The dart2js browser floor comes from the compiler, not from this package. The
Intl API it calls has been Baseline Widely available since September 2017, but
dart2js emits arrow functions in its own output, which is what puts Chrome at 45.
Flutter does not ship the Wasm build to every browser that supports it. Flutter's
loader gates Wasm on an engine allowlist that is Chromium-only, so Firefox and Safari
receive the JavaScript build by default even though both support WasmGC. Flutter
compiled to Wasm also cannot run in any iOS browser, since all of them are required to
use WebKit. The dart2wasm row above is the floor for the compiler output; whether a
given Flutter app reaches it is a separate question.
How it works #
| Platform | Source |
|---|---|
| Android | __system_property_get("persist.sys.timezone") via dart:ffi |
| iOS, macOS | [[NSTimeZone localTimeZone] name] via the Objective-C runtime |
| Linux | TZ, then the /etc/localtime symlink |
| Windows | GetDynamicTimeZoneInformation, mapped through CLDR |
| Web | Intl.DateTimeFormat().resolvedOptions().timeZone |
On Apple platforms the call goes through objc_msgSend against libobjc, which is
already loaded in every process, so nothing is bundled, signed, or notarized. Web
covers both dart2js and dart2wasm.
Testing #
The lookup can be replaced with a fixed value, so a test never depends on the zone the machine running it happens to be in:
LocalTimezone.setMockValue(
const NamedLocalTimezone(
name: 'Asia/Kolkata',
canonicalized: 'Asia/Kolkata',
raw: 'Asia/Kolkata',
),
);
addTearDown(LocalTimezone.clearMock);
setMock takes a whole LocalTimezone for a mock that should change its answer
between calls. In a background isolate, set it in that isolate's entrypoint: the
override is per-isolate, like every other static.
Additional information #
Issues and pull requests are welcome at github.com/BirjuVachhani/local_timezone.
Licensed under BSD 3-Clause.