Skip to content
Merged
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
66 changes: 44 additions & 22 deletions Sources/SkipFoundation/TimeZone.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,33 +42,35 @@ public struct TimeZone : Hashable, Codable, CustomStringConvertible, Sendable, K
}

public init?(identifier: String) {
guard let tz = java.util.TimeZone.getTimeZone(identifier) else {
let tz = java.util.TimeZone.getTimeZone(identifier)
// Java's getTimeZone() returns a timezone with ID "GMT" for unknown identifiers;
// if the result is GMT but the requested identifier wasn't "GMT", it means the
// identifier was not recognized.
if tz.getID() == "GMT" && identifier != "GMT" {
return nil
}
self.platformValue = tz
}

public init?(abbreviation: String) {
guard let identifier = Self.abbreviationDictionary[abbreviation] else {
}
guard let tz = java.util.TimeZone.getTimeZone(identifier) else {
guard let identifier = Self.abbreviationDictionary[abbreviation], let timeZone = TimeZone(identifier: identifier) else {
return nil
}
self.platformValue = tz
self.platformValue = timeZone.platformValue
}

public init?(secondsFromGMT seconds: Int) {
// java.time.ZoneId is more modern, but doesn't seem to be able to vend a java.util.TimeZone
// guard let tz = PlatformTimeZone.getTimeZone(java.time.ZoneId.ofOffset(seconds))

//let timeZoneId = seconds >= 0
// ? String.format("GMT+%02d:%02d", seconds / 3600, (seconds % 3600) / 60)
// : String.format("GMT-%02d:%02d", -seconds / 3600, (-seconds % 3600) / 60)
//guard let tz = PlatformTimeZone.getTimeZone(timeZoneId) else {
// return nil
//}

self.platformValue = java.util.SimpleTimeZone(seconds, "GMT")
// Foundation accepts offsets within 18 hours of GMT, including partial minutes.
guard seconds >= -18 * 3600 && seconds <= 18 * 3600 else {
return nil
}
// Round only the identifier to the nearest minute, preserving the exact offset.
let totalMinutes = (abs(seconds) + 30) / 60
let hours = totalMinutes / 60
let minutes = totalMinutes % 60
let sign = seconds >= 0 ? "+" : "-"
let identifier = totalMinutes == 0 ? "GMT" : String(format: "GMT%@%02d:%02d", sign, hours, minutes)
self.platformValue = java.util.SimpleTimeZone(seconds * 1000, identifier)
}

public init(from decoder: Decoder) throws {
Expand All @@ -87,7 +89,7 @@ public struct TimeZone : Hashable, Codable, CustomStringConvertible, Sendable, K
}

public func abbreviation(for date: Date = Date()) -> String? {
return platformValue.getDisplayName(true, java.util.TimeZone.SHORT)
return platformValue.getDisplayName(isDaylightSavingTime(for: date), java.util.TimeZone.SHORT)
}

public func secondsFromGMT(for date: Date = Date()) -> Int {
Expand Down Expand Up @@ -120,14 +122,34 @@ public struct TimeZone : Hashable, Codable, CustomStringConvertible, Sendable, K
}

public static var knownTimeZoneIdentifiers: [String] {
return Array(java.time.ZoneId.getAvailableZoneIds())
// Match the provider used by init(identifier:). On Android, java.time also
// advertises legacy aliases that java.util.TimeZone does not recognize.
return Array(java.util.TimeZone.getAvailableIDs().toList())
}

public static var knownTimeZoneNames: [String] {
return Array(java.time.ZoneId.getAvailableZoneIds())
}

public static var abbreviationDictionary: [String : String] = [:]
return knownTimeZoneIdentifiers
}

public static var abbreviationDictionary: [String : String] = {
var abbreviations: [String : String] = [:]
// Abbreviations are ambiguous. Keep the first identifier in sorted order so
// the mapping is stable, and use English names regardless of the device locale.
for identifier in knownTimeZoneIdentifiers.sorted() {
let timeZone = java.util.TimeZone.getTimeZone(identifier)
let standard = timeZone.getDisplayName(false, java.util.TimeZone.SHORT, java.util.Locale.US)
if abbreviations[standard] == nil {
abbreviations[standard] = identifier
}
if timeZone.useDaylightTime() {
let daylight = timeZone.getDisplayName(true, java.util.TimeZone.SHORT, java.util.Locale.US)
if abbreviations[daylight] == nil {
abbreviations[daylight] = identifier
}
}
}
return abbreviations
}()

@available(*, unavailable)
public static var timeZoneDataVersion: String {
Expand Down
119 changes: 102 additions & 17 deletions Tests/SkipFoundationTests/DateTime/TestTimeZone.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ class TestTimeZone: XCTestCase {
func test_knownTimeZoneNames() {
let known = NSTimeZone.knownTimeZoneNames
XCTAssertFalse(known.isEmpty, "known time zone names not expected to be empty")
XCTAssertEqual(Set(known), Set(TimeZone.knownTimeZoneIdentifiers))
}

func test_localizedName() {
Expand Down Expand Up @@ -143,20 +144,14 @@ class TestTimeZone: XCTestCase {
let actualName = tz2?.identifier
#if !SKIP
XCTAssertEqual(actualName, expectedName, "expected name \"\(expectedName)\" is not equal to \"\(actualName?.description ?? "")\"")
#endif
let expectedLocalizedName = "GMT-04:00"

#if SKIP
throw XCTSkip("Skip TimeZone.secondsFromGMT")
#else
let actualLocalizedName = tz2?.localizedName(for: .generic, locale: Locale(identifier: "en_US"))

XCTAssertEqual(actualLocalizedName, expectedLocalizedName, "expected name \"\(expectedLocalizedName)\" is not equal to \"\(actualLocalizedName?.description ?? "")\"")
#endif

let seconds2 = tz2?.secondsFromGMT() ?? 0
XCTAssertEqual(seconds2, -14400, "GMT-0400 should be -14400 seconds but got \(seconds2) instead")

#endif

let tz3 = TimeZone(identifier: "GMT-9999")
XCTAssertNil(tz3)

Expand All @@ -168,17 +163,107 @@ class TestTimeZone: XCTestCase {
}

func test_initializingTimeZoneWithAbbreviation() {
#if SKIP
throw XCTSkip("TODO: SkipTimeZone(abbreviation:)")
#endif
// Test invalid timezone abbreviation
var tz = TimeZone(abbreviation: "XXX")
let tz = TimeZone(abbreviation: "XXX")
XCTAssertNil(tz)
// Test valid timezone abbreviation of "AST" for "America/Halifax"
tz = TimeZone(abbreviation: "AST")
let expectedIdentifier = "America/Halifax"
let actualIdentifier = tz?.identifier
XCTAssertEqual(actualIdentifier, expectedIdentifier, "expected identifier \"\(expectedIdentifier)\" is not equal to \"\(actualIdentifier?.description ?? "")\"")
// Ambiguous abbreviations can resolve to different regions on each platform.
let tz2 = TimeZone(abbreviation: "AST")
XCTAssertNotNil(tz2)
let expectedIdentifier = TimeZone.abbreviationDictionary["AST"]
let actualIdentifier = tz2?.identifier
XCTAssertEqual(actualIdentifier, expectedIdentifier)
XCTAssertEqual(tz2?.secondsFromGMT(for: Date(timeIntervalSince1970: 1704067200.0)), -14400)
}

func test_initializingTimeZoneWithInvalidIdentifier() {
for identifier in ["", "Invalid/TimeZone", "GMT+25:00"] {
XCTAssertNil(TimeZone(identifier: identifier), identifier)
}
for identifier in ["GMT", "UTC", "GMT+00:00", "GMT-00:00", "America/New_York"] {
XCTAssertNotNil(TimeZone(identifier: identifier), identifier)
}
}

func test_initializingTimeZoneWithExactOffset() throws {
// Include partial minutes and both valid bounds to catch truncation and unit errors.
for seconds in [-64800, -19800, -61, -30, -1, 0, 1, 29, 30, 61, 19800, 20700, 64800] {
let timeZone = try XCTUnwrap(TimeZone(secondsFromGMT: seconds))
XCTAssertEqual(timeZone.secondsFromGMT(), seconds)
XCTAssertFalse(timeZone.isDaylightSavingTime())
}
for seconds in [-64801, 64801] {
XCTAssertNil(TimeZone(secondsFromGMT: seconds))
}
#if SKIP // Darwin Foundation traps for integer extremes.
XCTAssertNil(TimeZone(secondsFromGMT: Int.min))
XCTAssertNil(TimeZone(secondsFromGMT: Int.max))
#endif
}

func test_abbreviationUsesDate() throws {
#if SKIP
let originalLocale = java.util.Locale.getDefault()
defer { java.util.Locale.setDefault(originalLocale) }
java.util.Locale.setDefault(java.util.Locale.US)
#endif
let timeZone = try XCTUnwrap(TimeZone(identifier: "America/Los_Angeles"))
let winter = Date(timeIntervalSince1970: 1704067200.0) // January 1, 2024
let summer = Date(timeIntervalSince1970: 1719792000.0) // July 1, 2024
#if SKIP
XCTAssertEqual(timeZone.abbreviation(for: winter), "PST")
XCTAssertEqual(timeZone.abbreviation(for: summer), "PDT")
#else
XCTAssertEqual(timeZone.abbreviation(for: winter), timeZone.localizedName(for: .shortStandard, locale: Locale.current))
XCTAssertEqual(timeZone.abbreviation(for: summer), timeZone.localizedName(for: .shortDaylightSaving, locale: Locale.current))
#endif
}

func test_fixedOffsetUsedByCalendarAndDateFormatter() throws {
let timeZone = try XCTUnwrap(TimeZone(secondsFromGMT: 20700))
let date = Date(timeIntervalSince1970: 0.0)
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = timeZone
let components = calendar.dateComponents([.hour, .minute, .second], from: date)
XCTAssertEqual(components.hour, 5)
XCTAssertEqual(components.minute, 45)
XCTAssertEqual(components.second, 0)

let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = timeZone
formatter.dateFormat = "HH:mm:ss"
XCTAssertEqual(formatter.string(from: date), "05:45:00")
}

func test_abbreviationDictionaryResolvesSystemZones() throws {
XCTAssertFalse(TimeZone.abbreviationDictionary.isEmpty)
var abbreviations = ["GMT", "UTC", "AST", "PST", "PDT"]
#if SKIP
// Android can return GMT offset names where desktop Java returns ACST/ACDT.
// Both standard and daylight names supplied by the platform must resolve.
let adelaide = java.util.TimeZone.getTimeZone("Australia/Adelaide")
abbreviations.append(adelaide.getDisplayName(false, java.util.TimeZone.SHORT, java.util.Locale.US))
abbreviations.append(adelaide.getDisplayName(true, java.util.TimeZone.SHORT, java.util.Locale.US))
let kathmandu = java.util.TimeZone.getTimeZone("Asia/Kathmandu")
abbreviations.append(kathmandu.getDisplayName(false, java.util.TimeZone.SHORT, java.util.Locale.US))
#endif
for abbreviation in abbreviations {
let identifier = try XCTUnwrap(TimeZone.abbreviationDictionary[abbreviation], "Missing abbreviation: \(abbreviation)")
let timeZone = try XCTUnwrap(TimeZone(abbreviation: abbreviation))
XCTAssertEqual(timeZone.identifier, TimeZone(identifier: identifier)?.identifier)
}
for (_, identifier) in TimeZone.abbreviationDictionary {
XCTAssertNotNil(TimeZone(identifier: identifier), identifier)
}
}

func test_initializingTimeZoneWithCustomAbbreviations() {
let original = TimeZone.abbreviationDictionary
defer { TimeZone.abbreviationDictionary = original }
TimeZone.abbreviationDictionary = ["CUSTOM": "Asia/Tokyo", "INVALID": "Invalid/TimeZone"]
XCTAssertEqual(TimeZone(abbreviation: "CUSTOM")?.identifier, "Asia/Tokyo")
XCTAssertNil(TimeZone(abbreviation: "INVALID"))
XCTAssertNil(TimeZone(abbreviation: "MISSING"))
}

#if os(Windows)
Expand Down
Loading