iosswiftobjective-cdatetimenstimezone

How can I get GNU Lib C TZ format output from NSTimeZone?


I need to set the timezone information of a remote clock to the one on the iOS device.

The remote clock only supports GNU lib C TZ format of: std offset dst [offset],start[/time],end[/time]

e.g: EST+5EDT,M3.2.0/2,M11.1.0/2

So I need to produce a string similar to above from NSTimeZone.local time zone in Swift. Can't seem to access the current timezone rules as they would be in the IANA TZ database to produce the output.

Can this be done without the horrifying idea of caching a local copy of the TZ database in the app?

Update:

I haven't been able to find anything useful even through other programming languages. The best I was able to find was essentially parsing the tzfile in linux and making my own NSDictionary containing the info.


Solution

  • This was a fun exploration, largely because fitting the data into just the right format is pretty complex. Problem components:

    So, with all of this in place, we can fill out a crude method to produce a string in the format you need:

    extension TimeZone {
        struct Transition {
            let abbreviation: String
            let offsetFromGMT: Int
            let date: Date
            let components: DateComponents
    
            init(for timeZone: TimeZone, on date: Date, using referenceCalendar: Calendar) {
                abbreviation = timeZone.abbreviation(for: date) ?? ""
                offsetFromGMT = timeZone.secondsFromGMT(for: date)
                self.date = date
                components = referenceCalendar.dateComponents([.month, .weekOfMonth, .weekdayOrdinal, .hour, .minute, .second], from: date)
            }
        }
    
        func approximateTZEntryRule(on date: Date = Date(), using calendar: Calendar? = nil) -> String? {
            var referenceCalendar = calendar ?? Calendar(identifier: .gregorian)
            referenceCalendar.timeZone = self
    
            guard let year = referenceCalendar.dateInterval(of: .year, for: date) else {
                return nil
            }
    
            // If no prior DST transition has ever occurred, we're likely in a time zone which is either
            // standard or daylight year-round. We'll cap the definition here to the very start of the
            // year.
            let previousDSTTransition = Transition(for: self, on: previousDaylightSavingTimeTransition(before: date) ?? year.start, using: referenceCalendar)
    
            // Same with the following DST transition -- if no following DST transition will ever come,
            // we'll cap it to the end of the year.
            let nextDSTTransition = Transition(for: self, on: nextDaylightSavingTimeTransition(after: date) ?? year.end, using: referenceCalendar)
    
            let standardToDaylightTransition: Transition
            let daylightToStandardTransition: Transition
            if isDaylightSavingTime(for: date) {
                standardToDaylightTransition = previousDSTTransition
                daylightToStandardTransition = nextDSTTransition
            } else {
                standardToDaylightTransition = nextDSTTransition
                daylightToStandardTransition = previousDSTTransition
            }
    
            let standardAbbreviation = daylightToStandardTransition.abbreviation
            let standardOffset = formatOffset(daylightToStandardTransition.offsetFromGMT)
            let daylightAbbreviation = standardToDaylightTransition.abbreviation
            let startDate = formatDate(components: standardToDaylightTransition.components)
            let endDate = formatDate(components: daylightToStandardTransition.components)
            return "\(standardAbbreviation)\(standardOffset)\(daylightAbbreviation),\(startDate),\(endDate)"
        }
    
        /* These formatting functions can be way better. You'll also want to actually cache the
           DateComponentsFormatter somewhere.
         */
    
        func formatOffset(_ dateComponents: DateComponents) -> String {
            let formatter = DateComponentsFormatter()
            formatter.allowedUnits = [.hour, .minute, .second]
            formatter.zeroFormattingBehavior = .dropTrailing
            return formatter.string(from: dateComponents) ?? ""
        }
    
        func formatOffset(_ seconds: Int) -> String {
            return formatOffset(DateComponents(second: seconds))
        }
    
        func formatDate(components: DateComponents) -> String {
            let month = components.month ?? 0
            let week = components.weekOfMonth ?? 0
            let day = components.weekdayOrdinal ?? 0
            let offset = formatOffset(DateComponents(hour: components.hour, minute: components.minute, second: components.second))
            return "M\(month).\(week).\(day)/\(offset)"
        }
    }
    

    Note that there's lots to improve here, especially in clarity and performance. (Formatters are notoriously expensive, so you'll definitely want to cache them.) This also currently only produces dates in the expanded form "Mm.w.d" and not Julian days, but that can be bolted on. The code also assumes that it's "good enough" to restrict unbounded rules to the current calendar year, since this is what the GNU C library docs seem to imply about e.g. time zones which are always in standard/daylight time. (This also doesn't recognize well-known time zones like GMT/UTC, which might be sufficient to just write out as "GMT".)

    I have not extensively tested this code for various time zones, and the above code should be considered a basis for additional iteration. For my time zone of America/New_York, this produces "EST-5EDT,M3.3.2/3,M11.2.1/1", which appears correct to me at first glance, but many other edge cases might be good to explore:

    There's a lot more to this, and in general, I'd recommend trying to find an alternative method of setting a time on this device (preferably using named time zones), but this might hopefully at least get you started.