Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 74 additions & 14 deletions packages/dart/lib/src/objects/parse_installation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class ParseInstallation extends ParseObject {
keyParseVersion,
];
static String? _currentInstallationId;
static bool _timeZonesInitialized = false;

//Getters/setters
Map<String, dynamic> get acl => super.get<Map<String, dynamic>>(
Expand Down Expand Up @@ -83,8 +84,26 @@ class ParseInstallation extends ParseObject {
//Locale
set<String?>(keyLocaleIdentifier, ParseCoreData().locale);

//Timezone
set<String>(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<String>(keyTimeZone);
if (existingTimeZone == null || existingTimeZone.isEmpty) {
set<String>(keyTimeZone, _getNameLocalTimeZone());
}

//App info
set<String?>(keyAppName, ParseCoreData().appName);
Expand All @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
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
Expand Down
99 changes: 79 additions & 20 deletions packages/dart/test/parse_installation_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> _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<String>(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<String>(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<String>(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));
});
}