From 8c02135278a7dc15d56e586781a25de29561b3c0 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Thu, 21 May 2026 23:40:02 -0400 Subject: [PATCH 1/4] Fix timezone detection in ParseInstallation Improve local timezone name resolution by capturing DateTime.now() once to avoid DST race, preferring the OS-reported IANA zone when available, and falling back to matching a location whose current zone offset equals the local offset. This also fixes a bug where the old code compared incompatible types (int vs Duration) and could produce an empty string. Tests updated to initialize Parse/timezone data in setUpAll and to assert the installation's timeZone field exists, is non-empty, is either an IANA name or the OS-reported name, and (when matched via offset) has an offset equal to the system offset. --- .../lib/src/objects/parse_installation.dart | 39 +++++--- .../dart/test/parse_installation_test.dart | 88 ++++++++++++++----- 2 files changed, 96 insertions(+), 31 deletions(-) diff --git a/packages/dart/lib/src/objects/parse_installation.dart b/packages/dart/lib/src/objects/parse_installation.dart index da9fe4216..96b974bb1 100644 --- a/packages/dart/lib/src/objects/parse_installation.dart +++ b/packages/dart/lib/src/objects/parse_installation.dart @@ -95,20 +95,37 @@ class ParseInstallation extends ParseObject { String _getNameLocalTimeZone() { tz.initializeTimeZones(); - var locations = tz.timeZoneDatabase.locations; - Duration offset = DateTime.now().timeZoneOffset; - String name = ""; + // Capture once to avoid a DST-transition race between the two reads. + final DateTime now = DateTime.now(); - locations.forEach((key, value) { - for (var element in value.zones) { - if (element.offset == offset) { - name = value.name; - break; - } + // Prefer the OS-reported zone name when it's a valid IANA location + // (e.g. "America/New_York" on macOS/Linux/iOS/Android). Avoids the + // ambiguity of matching by offset, where many zones share an offset. + final String systemName = now.timeZoneName; + if (tz.timeZoneDatabase.locations.containsKey(systemName)) { + return systemName; + } + + // Fall back to a location whose *current* zone matches the local + // offset. The previous implementation scanned every historical zone + // (LMT, pre-DST, etc.) and compared a Duration against an int offset, + // which on timezone <0.11.0 is always false and produced "". + final int localOffsetMs = now.timeZoneOffset.inMilliseconds; + for (final location in tz.timeZoneDatabase.locations.values) { + final dynamic zoneOffset = location.currentTimeZone.offset; + final int zoneOffsetMs = zoneOffset is Duration + ? zoneOffset.inMilliseconds + : zoneOffset as int; + if (zoneOffsetMs == localOffsetMs) { + return location.name; } - }); - return name; + } + + // Last resort: return whatever the OS gave us rather than "". + // Note: on Windows/Web this may be a non-IANA name (e.g. + // "Pacific Standard Time" or "EDT"), but it's still better than "". + return systemName; } @override diff --git a/packages/dart/test/parse_installation_test.dart b/packages/dart/test/parse_installation_test.dart index a4b637b24..836e2eaa9 100644 --- a/packages/dart/test/parse_installation_test.dart +++ b/packages/dart/test/parse_installation_test.dart @@ -1,30 +1,78 @@ import 'package:parse_server_sdk/parse_server_sdk.dart'; import 'package:test/test.dart'; +import 'package:timezone/data/latest.dart' as tz; +import 'package:timezone/timezone.dart' as tz; + +Future _initParse() => Parse().initialize( + 'appId', + 'https://example.com', + debug: true, + fileDirectory: 'someDirectory', + appName: 'appName', + appPackageName: 'somePackageName', + appVersion: 'someAppVersion', +); void main() { - test('should return true for exist TimeZone.', () async { - // arrange - await Parse().initialize( - 'appId', - 'https://example.com', - debug: true, - // to prevent automatic detection - fileDirectory: 'someDirectory', - // to prevent automatic detection - appName: 'appName', - // to prevent automatic detection - appPackageName: 'somePackageName', - // to prevent automatic detection - appVersion: 'someAppVersion', + setUpAll(() async { + await _initParse(); + }); + + test('installation has a timeZone field', () async { + final installation = await ParseInstallation.currentInstallation(); + expect(installation.containsKey(keyTimeZone), isTrue); + }); + + // Regression: the SDK previously compared `int == Duration` when matching + // offsets against the timezone database. On timezone <0.11.0 that's always + // false, so the timeZone field was persisted as "". See + // _getNameLocalTimeZone() in parse_installation.dart. + test('installation timeZone is not empty', () async { + final installation = await ParseInstallation.currentInstallation(); + final tzValue = installation.get(keyTimeZone); + expect(tzValue, isNotNull); + expect( + tzValue, + isNotEmpty, + reason: 'Regression: timeZone was being stored as "".', + ); + }); + + test('installation timeZone is an IANA name or the OS-reported name', + () async { + tz.initializeTimeZones(); + final installation = await ParseInstallation.currentInstallation(); + final tzValue = installation.get(keyTimeZone)!; + + final bool isIana = tz.timeZoneDatabase.locations.containsKey(tzValue); + final bool matchesSystem = tzValue == DateTime.now().timeZoneName; + + expect( + isIana || matchesSystem, + isTrue, + reason: + 'timeZone "$tzValue" should be an IANA zone or the OS-reported ' + 'name (fallback for Windows/Web).', ); + }); + + test('when timeZone is matched via offset, its offset equals the local offset', + () async { + tz.initializeTimeZones(); + final installation = await ParseInstallation.currentInstallation(); + final tzValue = installation.get(keyTimeZone)!; - // act - final ParseInstallation installation = - await ParseInstallation.currentInstallation(); + final location = tz.timeZoneDatabase.locations[tzValue]; + if (location == null) { + // OS-reported, non-IANA fallback (Windows/Web). Nothing to verify. + return; + } - dynamic actualHasTimeZoneResult = installation.containsKey(keyTimeZone); + final dynamic zoneOffset = location.currentTimeZone.offset; + final int zoneOffsetMs = zoneOffset is Duration + ? zoneOffset.inMilliseconds + : zoneOffset as int; - // assert - expect(actualHasTimeZoneResult, true); + expect(zoneOffsetMs, equals(DateTime.now().timeZoneOffset.inMilliseconds)); }); } From 1da014750ba2247a5f746807d592c660614e26d7 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Fri, 22 May 2026 00:03:23 -0400 Subject: [PATCH 2/4] Initialize timezones once; normalize offsets Avoid repeatedly loading the timezone database by adding a process-wide _timeZonesInitialized flag and initializing tz data at most once in ParseInstallation. Add a helper _zoneOffsetMs to normalize TimeZone.offset (handles both int milliseconds on older timezone package versions and Duration on newer versions). Update tests to initialize timezones in setUpAll, clear persisted installation between tests to avoid stale data across DST boundaries, and capture DateTime.now() once per test to prevent racey comparisons. --- .../lib/src/objects/parse_installation.dart | 21 +++++++++++++------ .../dart/test/parse_installation_test.dart | 19 +++++++++++++---- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/dart/lib/src/objects/parse_installation.dart b/packages/dart/lib/src/objects/parse_installation.dart index 96b974bb1..ac482b65d 100644 --- a/packages/dart/lib/src/objects/parse_installation.dart +++ b/packages/dart/lib/src/objects/parse_installation.dart @@ -22,6 +22,7 @@ class ParseInstallation extends ParseObject { keyParseVersion, ]; static String? _currentInstallationId; + static bool _timeZonesInitialized = false; //Getters/setters Map get acl => super.get>( @@ -94,7 +95,11 @@ class ParseInstallation extends ParseObject { } String _getNameLocalTimeZone() { - tz.initializeTimeZones(); + // The timezone database is large; initialize it at most once per process. + if (!_timeZonesInitialized) { + tz.initializeTimeZones(); + _timeZonesInitialized = true; + } // Capture once to avoid a DST-transition race between the two reads. final DateTime now = DateTime.now(); @@ -113,11 +118,7 @@ class ParseInstallation extends ParseObject { // which on timezone <0.11.0 is always false and produced "". final int localOffsetMs = now.timeZoneOffset.inMilliseconds; for (final location in tz.timeZoneDatabase.locations.values) { - final dynamic zoneOffset = location.currentTimeZone.offset; - final int zoneOffsetMs = zoneOffset is Duration - ? zoneOffset.inMilliseconds - : zoneOffset as int; - if (zoneOffsetMs == localOffsetMs) { + if (_zoneOffsetMs(location.currentTimeZone.offset) == localOffsetMs) { return location.name; } } @@ -128,6 +129,14 @@ class ParseInstallation extends ParseObject { return systemName; } + // The `timezone` package returns `TimeZone.offset` as `int` (milliseconds) + // on <0.11.0 and as `Duration` on >=0.11.0. Normalize to milliseconds so + // the same comparison works across the full supported version range. + static int _zoneOffsetMs(dynamic offset) { + if (offset is Duration) return offset.inMilliseconds; + return offset as int; + } + @override Future create({ bool allowCustomObjectId = false, diff --git a/packages/dart/test/parse_installation_test.dart b/packages/dart/test/parse_installation_test.dart index 836e2eaa9..2c1c899b5 100644 --- a/packages/dart/test/parse_installation_test.dart +++ b/packages/dart/test/parse_installation_test.dart @@ -16,6 +16,16 @@ Future _initParse() => Parse().initialize( void main() { setUpAll(() async { await _initParse(); + // Initialize the timezone database once for the whole suite; it loads a + // large dataset and only needs to happen once per process. + tz.initializeTimeZones(); + }); + + // Each test re-derives the installation's timeZone from `DateTime.now()`, so + // it must not see a stale installation persisted by a previous test (which + // could differ across a DST boundary). + setUp(() async { + await ParseCoreData().getStore().remove(keyParseStoreInstallation); }); test('installation has a timeZone field', () async { @@ -40,12 +50,12 @@ void main() { test('installation timeZone is an IANA name or the OS-reported name', () async { - tz.initializeTimeZones(); + final now = DateTime.now(); final installation = await ParseInstallation.currentInstallation(); final tzValue = installation.get(keyTimeZone)!; final bool isIana = tz.timeZoneDatabase.locations.containsKey(tzValue); - final bool matchesSystem = tzValue == DateTime.now().timeZoneName; + final bool matchesSystem = tzValue == now.timeZoneName; expect( isIana || matchesSystem, @@ -58,7 +68,8 @@ void main() { test('when timeZone is matched via offset, its offset equals the local offset', () async { - tz.initializeTimeZones(); + // Capture once so a DST transition between reads can't make this flake. + final now = DateTime.now(); final installation = await ParseInstallation.currentInstallation(); final tzValue = installation.get(keyTimeZone)!; @@ -73,6 +84,6 @@ void main() { ? zoneOffset.inMilliseconds : zoneOffset as int; - expect(zoneOffsetMs, equals(DateTime.now().timeZoneOffset.inMilliseconds)); + expect(zoneOffsetMs, equals(now.timeZoneOffset.inMilliseconds)); }); } From 44ff19b5e6f65938d93ea9abc8bf12d3c001fa8e Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Fri, 22 May 2026 12:54:14 -0400 Subject: [PATCH 3/4] Only set timeZone if not already set Avoid overwriting a caller-provided timeZone on ParseInstallation. The change checks the existing value (via super.get) and only fills in the offset-matched local IANA name when the field is null or empty. Added comments explaining the offset-match fallback (which may pick an alphabetical IANA zone rather than a regionally accurate one), recommends apps set a real IANA name when available, and documents a first-launch caveat where the fallback can be persisted before caller code runs. --- .../lib/src/objects/parse_installation.dart | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/dart/lib/src/objects/parse_installation.dart b/packages/dart/lib/src/objects/parse_installation.dart index ac482b65d..1474aa9c1 100644 --- a/packages/dart/lib/src/objects/parse_installation.dart +++ b/packages/dart/lib/src/objects/parse_installation.dart @@ -84,8 +84,26 @@ class ParseInstallation extends ParseObject { //Locale set(keyLocaleIdentifier, ParseCoreData().locale); - //Timezone - set(keyTimeZone, _getNameLocalTimeZone()); + //Timezone — don't overwrite a value the caller already set. The pure-Dart + //offset-match here picks the first IANA zone with the local offset, which + //is alphabetical (e.g. "America/Anguilla" for UTC-4 instead of + //"America/New_York"). Apps that need a real IANA name (via a Flutter + //plugin like flutter_timezone, or a Kotlin/Swift channel) should set + //`timeZone` on the installation before calling save(); the SDK will only + //fill it in when nothing is set. + // + //First-launch caveat: _createInstallation() runs this method and then + //persists the full installation JSON to the local store before any + //caller code runs. That means the offset-matched fallback IS written to + //disk on first launch. If the app crashes before the caller's + //set+save() runs, the next launch reads the fallback from storage, the + //gate sees it as "existing", and the SDK won't auto-correct. The + //caller's set+save self-heals as soon as it runs. Apps that resolve a + //real IANA name should do so early in startup. + final String? existingTimeZone = super.get(keyTimeZone); + if (existingTimeZone == null || existingTimeZone.isEmpty) { + set(keyTimeZone, _getNameLocalTimeZone()); + } //App info set(keyAppName, ParseCoreData().appName); From 823c3d4d02ac4633bb0d02178f3b0aee47f81f3e Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Fri, 22 May 2026 14:56:21 -0400 Subject: [PATCH 4/4] Avoid guessing timezone when offset is ambiguous Change timezone fallback logic to only return an IANA zone if exactly one location matches the local offset. Many zones share the same instant offset (e.g. America/Los_Angeles and America/Vancouver), so returning the first match could fabricate a wrong location. The code now records a single match and clears it if a second match is found, falling back to the OS string when ambiguous. Also centralizes the local offset calculation and fixes comparisons against historical zone offsets. --- .../lib/src/objects/parse_installation.dart | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/dart/lib/src/objects/parse_installation.dart b/packages/dart/lib/src/objects/parse_installation.dart index 1474aa9c1..c2a5bbf70 100644 --- a/packages/dart/lib/src/objects/parse_installation.dart +++ b/packages/dart/lib/src/objects/parse_installation.dart @@ -131,14 +131,30 @@ class ParseInstallation extends ParseObject { } // Fall back to a location whose *current* zone matches the local - // offset. The previous implementation scanned every historical zone + // offset, but only if exactly one IANA zone matches. Many zones share + // an offset at any instant (e.g. America/Los_Angeles, America/Vancouver, + // America/Tijuana), so returning the first match would arbitrarily pick + // a wrong location. Returning the OS string instead is non-IANA but at + // least not a fabricated guess. + // + // The previous implementation also scanned every historical zone // (LMT, pre-DST, etc.) and compared a Duration against an int offset, // which on timezone <0.11.0 is always false and produced "". final int localOffsetMs = now.timeZoneOffset.inMilliseconds; + String? offsetMatch; for (final location in tz.timeZoneDatabase.locations.values) { - if (_zoneOffsetMs(location.currentTimeZone.offset) == localOffsetMs) { - return location.name; + if (_zoneOffsetMs(location.currentTimeZone.offset) != localOffsetMs) { + continue; + } + if (offsetMatch != null) { + // Ambiguous: multiple zones share this offset. Don't guess. + offsetMatch = null; + break; } + offsetMatch = location.name; + } + if (offsetMatch != null) { + return offsetMatch; } // Last resort: return whatever the OS gave us rather than "".