Home > Software design >  How can I get GNU Lib C TZ format output from NSTimeZone?
How can I get GNU Lib C TZ format output from NSTimeZone?

Time:09-28

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.

CodePudding user response:

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

  • We need the "current" TZ database rule that applies for a given time zone. This is a bit of a loaded concept, because:

    1. Darwin platforms don't actually use the TZ database directly for most applications, but instead use ICU's time zone database, which comes in a different format and is more complex. Even if you produce a string in this format, it's not necessarily descriptive of the actual time behavior on device

    2. While it is possible to read and parse the TZ database on iOS dynamically, the TZ database itself is not guaranteed to store information in the format needed here. rfc8536, the RFC governing the Time Zone Information Format says the following about the format you want:

      The TZ string in a version 3 TZif file MAY use the following extensions to POSIX TZ strings. These extensions are described using the terminology of Section 8.3 of the "Base Definitions" volume of [POSIX].

      Example: <-03>3<-02>,M3.5.0/-2,M10.5.0/-1
      Example: EST5EDT,0/0,J365/25

      While spelunking through the iOS TZ database, I found some database entries that do offer a rule at the end of the file in this format, but they appear to be a minority. You could parse these dynamically, but it's likely not worth it

    So, we need to use APIs to produce a string in this format.

  • In order to produce a "rule" that is at least approximately correct on a given date, you need to know information about DST transitions around that date. This is an extremely thorny topic, because DST rules change all the time, and don't always make as much sense as you'd hope. At the very least:

    • Many time zones in the Northern hemisphere observe DST beginning in the spring and ending in the fall
    • Many time zones in the Southern hemisphere observe DST beginning in the fall and ending in the spring
    • Some time zones don't observe DST (are in standard time year-round)
    • Some time zones don't observe DST and are in daylight time year-round

    Because the rules are so complex, the rest of this answer assumes you're okay with producing a "good enough" answer that represents a specific date in time, and is willing to send further strings to your clock some time in the future when corrections are needed. e.g., to describe "now", we will be assuming that producing a rule based off of the last DST transition (if any) and the next DST transition (if any) is "good enough", but this may not work for all situations in many time zones

  • Foundation provides DST transition information on TimeZone in the form of TimeZone.nextDaylightSavingTimeTransition/TimeZone.nextDaylightSavingTimeTransition(after:). Frustratingly, however, there's no way to get information about previous DST transitions, so we'll need to rectify that:

    • Foundation's localization support (including calendars and time zones) is based directly on the ICU library, which ships internally on all Apple platforms. ICU does provide a way to get information about previous DST transitions, but Foundation just doesn't offer this as API, so we'll need to expose it ourselves

    • ICU is a semi-private library on Apple platforms. The library is guaranteed to be present, and Xcode will offer you libicucore.tbd to link against in <Project> > <Target> > Build Phases > Link Binary with Libraries, but the actual headers and symbols are not directly exposed to apps. You can successfully link against libicucore, but you'll need to forward-declare the functionality we need in an Obj-C header imported into Swift

    • Somewhere in the Swift project, we need to expose the following ICU functionality:

      #include <stdint.h>
      
      typedef void * _Nonnull UCalendar;
      typedef double UDate;
      typedef int8_t UBool;
      typedef uint16_t UChar;
      
      typedef enum UTimeZoneTransitionType {
          UCAL_TZ_TRANSITION_NEXT,
          UCAL_TZ_TRANSITION_NEXT_INCLUSIVE,
          UCAL_TZ_TRANSITION_PREVIOUS,
          UCAL_TZ_TRANSITION_PREVIOUS_INCLUSIVE,
      } UTimeZoneTransitionType;
      
      typedef enum UCalendarType {
          UCAL_TRADITIONAL,
          UCAL_DEFAULT,
          UCAL_GREGORIAN,
      } UCalendarType;
      
      typedef enum UErrorCode {
          U_ZERO_ERROR = 0,
      } UErrorCode;
      
      UCalendar * _Nullable ucal_open(const UChar *zoneID, int32_t len, const char *locale, UCalendarType type, UErrorCode *status);
      void ucal_setMillis(const UCalendar * _Nonnull cal, UDate date, UErrorCode * _Nonnull status);
      UBool ucal_getTimeZoneTransitionDate(const UCalendar * _Nonnull cal, UTimeZoneTransitionType type, UDate * _Nonnull transition, UErrorCode * _Nonnull status);
      

      These are all forward declarations / constants, so no need to worry about implementation (since we get that by linking against libicucore).

    • You can see the values in UTimeZoneTransitionTypeTimeZone.nextDaylightSavingTimeTransition just calls ucal_getTimeZoneTransitionDate with a value of UCAL_TZ_TRANSITION_NEXT, so we can offer roughly the same functionality by calling the method with UCAL_TZ_TRANSITION_PREVIOUS:

      extension TimeZone {
          func previousDaylightSavingTimeTransition(before: Date) -> Date? {
              // We _must_ pass a status variable for `ucal_open` to write into, but the actual initial
              // value doesn't matter.
              var status = U_ZERO_ERROR
      
              // `ucal_open` requires the time zone identifier be passed in as UTF-16 code points.
              // `String.utf16` doesn't offer a contiguous buffer for us to pass directly into `ucal_open`
              // so we have to create our own by copying the values into an `Array`, then
              let timeZoneIdentifier = Array(identifier.utf16)
              guard let calendar = Locale.current.identifier.withCString({ localeIdentifier in
                  ucal_open(timeZoneIdentifier, // implicit conversion of Array to a pointer, but convenient!
                            Int32(timeZoneIdentifier.count),
                            localeIdentifier,
                            UCAL_GREGORIAN,
                            &status)
              }) else {
                  // Figure out some error handling here -- we failed to find a "calendar" for this time
                  // zone; i.e., there's no time zone date for this time zone.
                  //
                  // With more enum cases copied from `UErrorCode` you may find a good way to report an
                  // error here if needed. `u_errorName` turns a `UErrorCode` into a string.
                  return nil
              }
      
              // `UCalendar` functions operate on the calendar's current timestamp, so we have to apply
              // `date` to it. `UDate`s are the number of milliseconds which have passed since January 1,
              // 1970, while `Date` offers its time interval in seconds.
              ucal_setMillis(calendar, before.timeIntervalSince1970 * 1000.0, &status)
      
              var result: UDate = 0
              guard ucal_getTimeZoneTransitionDate(calendar, UCAL_TZ_TRANSITION_PREVIOUS, &result, &status) != 0 else {
                  // Figure out some error handling here -- same as above (check status).
                  return nil
              }
      
              // Same transition but in reverse.
              return Date(timeIntervalSince1970: result / 1000.0)
          }
      }
      

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:

  • Boundary conditions around the start/end of the year
  • Giving a date which exactly matches a DST transition (consider TRANSITION_PREVIOUS vs. TRANSITION_PREVIOUS_INCLUSIVE)
  • Time zones which are always standard/daylight
  • Non-standard daylight/timezone offsets

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.

  • Related