Skip to content

Commit b9c791c

Browse files
authored
TimeZone, Calendar, and Locale disagree on how .current is serialized (#1491)
* (157160558) TimeZone, Calendar, and Locale disagree on how .current is encoded * Revert roundtripEncoding back to its original state * Address feedback
1 parent 23013ca commit b9c791c

File tree

10 files changed

+352
-61
lines changed

10 files changed

+352
-61
lines changed

Sources/FoundationEssentials/Calendar/Calendar.swift

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1616,19 +1616,12 @@ extension Calendar : Codable {
16161616
public init(from decoder: Decoder) throws {
16171617
let container = try decoder.container(keyedBy: CodingKeys.self)
16181618

1619-
if let current = try container.decodeIfPresent(Current.self, forKey: .current) {
1620-
switch current {
1621-
case .autoupdatingCurrent:
1622-
self = Calendar.autoupdatingCurrent
1623-
return
1624-
case .current:
1625-
self = Calendar.current
1626-
return
1627-
case .fixed:
1628-
// Fall through to identifier-based
1629-
break
1630-
}
1619+
if let current = try container.decodeIfPresent(Current.self, forKey: .current), current == .autoupdatingCurrent {
1620+
self = Calendar.autoupdatingCurrent
1621+
return
16311622
}
1623+
1624+
// Just like TimeZone and Locale, Whether the calendar was fixed or current we decode as a fixed calendar if it wasn't encoded as the sentinel autoupdating current
16321625

16331626
let identifierString = try container.decode(String.self, forKey: .identifier)
16341627
// Same as NSCalendar.Identifier
@@ -1655,7 +1648,8 @@ extension Calendar : Codable {
16551648
try container.encode(self.firstWeekday, forKey: .firstWeekday)
16561649
try container.encode(self.minimumDaysInFirstWeek, forKey: .minimumDaysInFirstWeek)
16571650

1658-
// current and autoupdatingCurrent are sentinel values. Calendar could theoretically not treat 'current' as a sentinel, but it is required for Locale (one of the properties of Calendar), so transitively we have to do the same here
1651+
// autoupdatingCurrent is a sentinel value
1652+
// Prior to FoundationPreview 6.3 releases, Calendar treated current-equivalent calendars as sentinel values while decoding as well. As of FoundationPreview 6.2 releases, Calendar no longer decodes the current sentinel value, but it is still encoded to preserve behavior when decoding with older runtimes
16591653
if self == Calendar.autoupdatingCurrent {
16601654
try container.encode(Current.autoupdatingCurrent, forKey: .current)
16611655
} else if self == Calendar.current {

Sources/FoundationEssentials/Locale/Locale.swift

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ public struct Locale : Hashable, Equatable, Sendable {
9494
// On Darwin, this overrides are applied on top of CFPreferences.
9595
return LocaleCache.cache.localeAsIfCurrent(name: name, overrides: overrides, disableBundleMatching: disableBundleMatching)
9696
}
97+
98+
internal static func localeWithPreferences(identifier: String, preferences: LocalePreferences) -> Locale {
99+
return LocaleCache.cache.localeWithPreferences(identifier: identifier, prefs: preferences)
100+
}
97101

98102
internal static func localeAsIfCurrentWithBundleLocalizations(_ availableLocalizations: [String], allowsMixedLocalizations: Bool) -> Locale? {
99103
return LocaleCache.cache.localeAsIfCurrentWithBundleLocalizations(availableLocalizations, allowsMixedLocalizations: allowsMixedLocalizations)
@@ -814,6 +818,7 @@ extension Locale : Codable {
814818
private enum CodingKeys : Int, CodingKey {
815819
case identifier
816820
case current
821+
case preferences
817822
}
818823

819824
// CFLocale enforces a rule that fixed/current/autoupdatingCurrent can never be equal even if their values seem like they are the same
@@ -825,23 +830,35 @@ extension Locale : Codable {
825830

826831
public init(from decoder: Decoder) throws {
827832
let container = try decoder.container(keyedBy: CodingKeys.self)
833+
let prefs = try container.decodeIfPresent(LocalePreferences.self, forKey: .preferences)
828834

829835
if let current = try container.decodeIfPresent(Current.self, forKey: .current) {
830836
switch current {
831837
case .autoupdatingCurrent:
832838
self = Locale.autoupdatingCurrent
833839
return
834840
case .current:
835-
self = Locale.current
836-
return
841+
if prefs == nil {
842+
// Prior to FoundationPreview 6.3 releases, Locale did not encode preferences and expected decoding .current to decode with the new current user's preferences via the new process' .currrent locale
843+
// Preserve behavior for encoded current locales without encoded preferences by decoding as the current locale here to preserve the intent of including user preferences even though preferences are not included in the archive
844+
// If preferences were encoded (the current locale encoded from a post-FoundationPreview 6.3 release), fallthrough to the new behavior below
845+
self = Locale.current
846+
return
847+
}
837848
case .fixed:
838849
// Fall through to identifier-based
839850
break
840851
}
841852
}
842853

843854
let identifier = try container.decode(String.self, forKey: .identifier)
844-
self.init(identifier: identifier)
855+
if let prefs {
856+
// If preferences were encoded, create a locale with the preferences and identifier (not including preferences from the current user)
857+
self = Locale.localeWithPreferences(identifier: identifier, preferences: prefs)
858+
} else {
859+
// If no preferences were encoded, create a fixed locale with just the identifier
860+
self.init(identifier: identifier)
861+
}
845862
}
846863

847864
public func encode(to encoder: Encoder) throws {
@@ -852,9 +869,15 @@ extension Locale : Codable {
852869
if self == Locale.autoupdatingCurrent {
853870
try container.encode(Current.autoupdatingCurrent, forKey: .current)
854871
} else if self == Locale.current {
872+
// Always encode .current for the current locale to preserve existing decoding behavior of .current when decoding on older runtimes prior to FoundationPreview 6.3 releases
855873
try container.encode(Current.current, forKey: .current)
856874
} else {
857875
try container.encode(Current.fixed, forKey: .current)
858876
}
877+
878+
if let prefs {
879+
// Encode preferences (if present) so that when decoding on newer runtimes (FoundationPreview 6.3 releases and later) we create a locale with the preferences as they are at encode time
880+
try container.encode(prefs, forKey: .preferences)
881+
}
859882
}
860883
}

Sources/FoundationEssentials/Locale/Locale_Preferences.swift

Lines changed: 197 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ internal import _ForSwiftFoundation
1717

1818
/// Holds user preferences about `Locale`, retrieved from user defaults. It is only used when creating the `current` Locale. Fixed-identifier locales never have preferences.
1919
package struct LocalePreferences: Hashable, Sendable {
20-
package enum MeasurementUnit {
20+
package enum MeasurementUnit: Int, Codable {
2121
case centimeters
2222
case inches
2323

@@ -38,7 +38,7 @@ package struct LocalePreferences: Hashable, Sendable {
3838
}
3939
}
4040

41-
package enum TemperatureUnit {
41+
package enum TemperatureUnit: Int, Codable {
4242
case fahrenheit
4343
case celsius
4444

@@ -66,7 +66,7 @@ package struct LocalePreferences: Hashable, Sendable {
6666
package var firstWeekday: [Calendar.Identifier : Int]?
6767
package var minDaysInFirstWeek: [Calendar.Identifier : Int]?
6868
#if FOUNDATION_FRAMEWORK
69-
struct ICUSymbolsAndStrings : Hashable, @unchecked Sendable {
69+
package struct ICUSymbolsAndStrings : Hashable, @unchecked Sendable {
7070
// The following `CFDictionary` ivars are used directly by `CFDateFormatter`. Keep them as `CFDictionary` to avoid bridging them into and out of Swift. We don't need to access them from Swift at all.
7171

7272
package var icuDateTimeSymbols: CFDictionary?
@@ -91,6 +91,8 @@ package struct LocalePreferences: Hashable, Sendable {
9191
package var temperatureUnit: TemperatureUnit?
9292
package var force24Hour: Bool?
9393
package var force12Hour: Bool?
94+
95+
// Note: When adding new preferences, be sure to include them in the serialized format via the Codable conformance below
9496

9597
package init() { }
9698

@@ -108,7 +110,8 @@ package struct LocalePreferences: Hashable, Sendable {
108110
force24Hour: Bool? = nil,
109111
force12Hour: Bool? = nil,
110112
numberSymbols: [UInt32 : String]? = nil,
111-
dateFormats: [Date.FormatStyle.DateStyle: String]? = nil) {
113+
dateFormats: [Date.FormatStyle.DateStyle: String]? = nil,
114+
icuSymbolsAndStrings: ICUSymbolsAndStrings = ICUSymbolsAndStrings()) {
112115

113116
self.metricUnits = metricUnits
114117
self.languages = languages
@@ -123,6 +126,7 @@ package struct LocalePreferences: Hashable, Sendable {
123126
self.force12Hour = force12Hour
124127
self.numberSymbols = numberSymbols
125128
self.dateFormats = dateFormats
129+
self.icuSymbolsAndStrings = icuSymbolsAndStrings
126130
}
127131
#else
128132
package init(metricUnits: Bool? = nil,
@@ -331,3 +335,192 @@ package struct LocalePreferences: Hashable, Sendable {
331335
}
332336
}
333337
}
338+
339+
extension LocalePreferences: Codable {
340+
private enum CodingKeys: String, CodingKey {
341+
case metricUnits = "metric"
342+
case languages = "langs"
343+
case locale
344+
case collationOrder = "coll"
345+
case firstWeekday = "weekFirst"
346+
case minDaysInFirstWeek = "weekMin"
347+
case icuSymbolsAndStrings = "icu"
348+
case dateFormats = "dates"
349+
case numberSymbols = "nums"
350+
case country
351+
case measurementUnits = "meas"
352+
case temperatureUnit = "temp"
353+
case force24Hour = "24h"
354+
case force12Hour = "12h"
355+
}
356+
357+
package func encode(to encoder: any Encoder) throws {
358+
var container = encoder.container(keyedBy: CodingKeys.self)
359+
try container.encodeIfPresent(metricUnits, forKey: .metricUnits)
360+
try container.encodeIfPresent(languages, forKey: .languages)
361+
try container.encodeIfPresent(locale, forKey: .locale)
362+
try container.encodeIfPresent(collationOrder, forKey: .collationOrder)
363+
let firstWeekdayMapped = self.firstWeekday.map {
364+
var result = [String : Int]()
365+
for (identifier, day) in $0 {
366+
result[identifier.cfCalendarIdentifier] = day
367+
}
368+
return result
369+
}
370+
try container.encodeIfPresent(firstWeekdayMapped, forKey: .firstWeekday)
371+
let minDaysInFirstWeekMapped = self.minDaysInFirstWeek.map {
372+
var result = [String : Int]()
373+
for (identifier, days) in $0 {
374+
result[identifier.cldrIdentifier] = days
375+
}
376+
return result
377+
}
378+
try container.encodeIfPresent(minDaysInFirstWeekMapped, forKey: .minDaysInFirstWeek)
379+
#if FOUNDATION_FRAMEWORK
380+
if icuSymbolsAndStrings.containsValuesToSerialize {
381+
try container.encodeIfPresent(icuSymbolsAndStrings, forKey: .icuSymbolsAndStrings)
382+
}
383+
#if !NO_FORMATTERS
384+
try container.encodeIfPresent(dateFormats.map {
385+
var result = [UInt : String]()
386+
for (format, value) in $0 {
387+
result[format.rawValue] = value
388+
}
389+
return result
390+
}, forKey: .dateFormats)
391+
#endif
392+
#endif
393+
try container.encodeIfPresent(numberSymbols, forKey: .numberSymbols)
394+
try container.encodeIfPresent(country, forKey: .country)
395+
try container.encodeIfPresent(measurementUnits, forKey: .measurementUnits)
396+
try container.encodeIfPresent(temperatureUnit, forKey: .temperatureUnit)
397+
try container.encodeIfPresent(force24Hour, forKey: .force24Hour)
398+
try container.encodeIfPresent(force12Hour, forKey: .force12Hour)
399+
}
400+
401+
package init(from decoder: any Decoder) throws {
402+
let container = try decoder.container(keyedBy: CodingKeys.self)
403+
let metricUnits = try container.decodeIfPresent(Bool.self, forKey: .metricUnits)
404+
let languages = try container.decodeIfPresent([String].self, forKey: .languages)
405+
let locale = try container.decodeIfPresent(String.self, forKey: .locale)
406+
let collationOrder = try container.decodeIfPresent(String.self, forKey: .collationOrder)
407+
let firstWeekday = try container.decodeIfPresent([String : Int].self, forKey: .firstWeekday).map {
408+
var result = [Calendar.Identifier : Int]()
409+
for (stringIdentifier, day) in $0 {
410+
guard let identifier = Calendar.Identifier(identifierString: stringIdentifier) else {
411+
throw DecodingError.dataCorruptedError(forKey: .firstWeekday, in: container, debugDescription: "Unknown calendar identifier '\(stringIdentifier)'")
412+
}
413+
result[identifier] = day
414+
}
415+
return result
416+
}
417+
let minDaysInFirstWeek = try container.decodeIfPresent([String : Int].self, forKey: .minDaysInFirstWeek).map {
418+
var result = [Calendar.Identifier : Int]()
419+
for (stringIdentifier, days) in $0 {
420+
guard let identifier = Calendar.Identifier(identifierString: stringIdentifier) else {
421+
throw DecodingError.dataCorruptedError(forKey: .minDaysInFirstWeek, in: container, debugDescription: "Unknown calendar identifier '\(stringIdentifier)'")
422+
}
423+
result[identifier] = days
424+
}
425+
return result
426+
}
427+
let country = try container.decodeIfPresent(String.self, forKey: .country)
428+
let measurementUnits = try container.decodeIfPresent(MeasurementUnit.self, forKey: .measurementUnits)
429+
let temperatureUnit = try container.decodeIfPresent(TemperatureUnit.self, forKey: .temperatureUnit)
430+
let force24Hour = try container.decodeIfPresent(Bool.self, forKey: .force24Hour)
431+
let force12Hour = try container.decodeIfPresent(Bool.self, forKey: .force12Hour)
432+
let numberSymbols = try container.decodeIfPresent([UInt32 : String].self, forKey: .numberSymbols)
433+
434+
#if FOUNDATION_FRAMEWORK && !NO_FORMATTERS
435+
let dateFormats = try container.decodeIfPresent([UInt: String].self, forKey: .dateFormats).map {
436+
var result = [Date.FormatStyle.DateStyle : String]()
437+
for (rawValue, format) in $0 {
438+
result[Date.FormatStyle.DateStyle(rawValue: rawValue)] = format
439+
}
440+
return result
441+
}
442+
let icuDateFormats = dateFormats.map {
443+
var cfResult = [String : String]()
444+
for (style, format) in $0 {
445+
cfResult["\(style.rawValue)"] = format
446+
}
447+
return cfResult as CFDictionary
448+
}
449+
let icuNumberSymbols = numberSymbols.map {
450+
var result = [String : String]()
451+
for (rawValue, symbol) in $0 {
452+
result["\(rawValue)"] = symbol
453+
}
454+
return result as CFDictionary
455+
}
456+
var icuSymbolsAndStrings = try container.decodeIfPresent(ICUSymbolsAndStrings.self, forKey: .icuSymbolsAndStrings) ?? ICUSymbolsAndStrings()
457+
icuSymbolsAndStrings.icuDateFormatStrings = icuDateFormats
458+
icuSymbolsAndStrings.icuNumberSymbols = icuNumberSymbols
459+
460+
self.init(
461+
metricUnits: metricUnits,
462+
languages: languages,
463+
locale: locale,
464+
collationOrder: collationOrder,
465+
firstWeekday: firstWeekday,
466+
minDaysInFirstWeek: minDaysInFirstWeek,
467+
country: country,
468+
measurementUnits: measurementUnits,
469+
temperatureUnit: temperatureUnit,
470+
force24Hour: force24Hour,
471+
force12Hour: force12Hour,
472+
numberSymbols: numberSymbols,
473+
dateFormats: dateFormats,
474+
icuSymbolsAndStrings: icuSymbolsAndStrings
475+
)
476+
#else
477+
self.init(
478+
metricUnits: metricUnits,
479+
languages: languages,
480+
locale: locale,
481+
collationOrder: collationOrder,
482+
firstWeekday: firstWeekday,
483+
minDaysInFirstWeek: minDaysInFirstWeek,
484+
country: country,
485+
measurementUnits: measurementUnits,
486+
temperatureUnit: temperatureUnit,
487+
force24Hour: force24Hour,
488+
force12Hour: force12Hour,
489+
numberSymbols: numberSymbols
490+
)
491+
#endif
492+
}
493+
}
494+
495+
#if FOUNDATION_FRAMEWORK
496+
extension LocalePreferences.ICUSymbolsAndStrings: Codable {
497+
var containsValuesToSerialize: Bool {
498+
icuDateTimeSymbols != nil || icuTimeFormatStrings != nil || icuNumberFormatStrings != nil
499+
}
500+
501+
private enum CodingKeys: String, CodingKey {
502+
case icuDateTimeSymbols = "dtSym"
503+
case icuTimeFormatStrings = "times"
504+
case icuNumberFormatStrings = "nums"
505+
}
506+
507+
package func encode(to encoder: any Encoder) throws {
508+
var container = encoder.container(keyedBy: CodingKeys.self)
509+
try container.encodeIfPresent(icuDateTimeSymbols.map { $0 as? [String : [String]] }, forKey: .icuDateTimeSymbols)
510+
try container.encodeIfPresent(icuTimeFormatStrings.map { $0 as? [String : String] }, forKey: .icuTimeFormatStrings)
511+
try container.encodeIfPresent(icuNumberFormatStrings.map { $0 as? [String : String] }, forKey: .icuNumberFormatStrings)
512+
}
513+
514+
package init(from decoder: any Decoder) throws {
515+
let container = try decoder.container(keyedBy: CodingKeys.self)
516+
517+
self.icuDateTimeSymbols = try container.decodeIfPresent([String : [String]].self, forKey: .icuDateTimeSymbols).map { $0 as CFDictionary }
518+
self.icuTimeFormatStrings = try container.decodeIfPresent([String : String].self, forKey: .icuTimeFormatStrings).map { $0 as CFDictionary }
519+
self.icuNumberFormatStrings = try container.decodeIfPresent([String : String].self, forKey: .icuNumberFormatStrings).map { $0 as CFDictionary }
520+
521+
// Will be filled in by LocalePreferences serialized value
522+
self.icuDateFormatStrings = nil
523+
self.icuNumberSymbols = nil
524+
}
525+
}
526+
#endif

Sources/FoundationEssentials/TimeZone/TimeZone.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,9 @@ extension TimeZone : Codable {
335335
var container = encoder.container(keyedBy: CodingKeys.self)
336336
// Even if we are autoupdatingCurrent, encode the identifier for backward compatibility
337337
try container.encode(self.identifier, forKey: .identifier)
338+
339+
// Autoupdating current timezones are treated as sentinel values, but the current TimeZone is encoded as a fixed TimeZone
340+
// This is the same behavior as Locale/Calendar except it did not previously encode as a sentinel value before FoundationPreview 6.3, so no extra key is encoded for the current time zone
338341
if _tz.isAutoupdating {
339342
try container.encode(true, forKey: .autoupdating)
340343
}

Sources/FoundationEssentials/TimeZone/TimeZone_Cache.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ struct TimeZoneCache : Sendable, ~Copyable {
100100
#endif
101101
return oldTimeZone
102102
}
103+
104+
mutating func resetCurrent(to newValue: TimeZone) {
105+
currentTimeZone = newValue
106+
#if FOUNDATION_FRAMEWORK
107+
bridgedCurrentTimeZone = nil
108+
#endif
109+
}
103110

104111
/// Reads from environment variables `TZFILE`, `TZ` and finally the symlink pointed at by the C macro `TZDEFAULT` to figure out what the current (aka "system") time zone is.
105112
mutating func findCurrentTimeZone() -> TimeZone {
@@ -398,6 +405,10 @@ struct TimeZoneCache : Sendable, ~Copyable {
398405
func reset() -> TimeZone? {
399406
return lock.withLock { $0.reset() }
400407
}
408+
409+
func resetCurrent(to newValue: TimeZone) {
410+
return lock.withLock { $0.resetCurrent(to: newValue) }
411+
}
401412

402413
var current: TimeZone {
403414
lock.withLock { $0.current() }

0 commit comments

Comments
 (0)