diff --git a/packages/dart/lib/src/objects/parse_installation.dart b/packages/dart/lib/src/objects/parse_installation.dart index da9fe4216..c2a5bbf70 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>( @@ -83,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); @@ -94,21 +113,62 @@ class ParseInstallation extends ParseObject { } String _getNameLocalTimeZone() { - tz.initializeTimeZones(); - var locations = tz.timeZoneDatabase.locations; + // The timezone database is large; initialize it at most once per process. + if (!_timeZonesInitialized) { + tz.initializeTimeZones(); + _timeZonesInitialized = true; + } - 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, 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) { + continue; + } + if (offsetMatch != null) { + // Ambiguous: multiple zones share this offset. Don't guess. + offsetMatch = null; + break; } - }); - return name; + offsetMatch = location.name; + } + if (offsetMatch != null) { + return offsetMatch; + } + + // 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; + } + + // 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 diff --git a/packages/dart/test/parse_installation_test.dart b/packages/dart/test/parse_installation_test.dart index a4b637b24..2c1c899b5 100644 --- a/packages/dart/test/parse_installation_test.dart +++ b/packages/dart/test/parse_installation_test.dart @@ -1,30 +1,89 @@ 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(); + // 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 { + 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 { + 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 == 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 { + // 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)!; - // 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(now.timeZoneOffset.inMilliseconds)); }); }