-1

Using Java I have a SimpleTimeZone instance with GMT offset and daylight saving time information from a legacy system.

I would like to retrieve ZoneId to be able to use Java 8 time API.

Actually, toZoneId returns a ZoneId without the daylight Saving time infos

SimpleTimeZone stz = new SimpleTimeZone( 2 * 60 * 60 * 1000, "GMT", Calendar.JANUARY,1,1,1, Calendar.FEBRUARY,1,1,1, 1 * 60 * 60 * 1000);
stz.toZoneId();
Woody
  • 809
  • 8
  • 21
  • 2
    Please look at the Javadoc. `TimeZone` has a `toZoneId()` method. – Sotirios Delimanolis Jun 06 '17 at 19:14
  • If i use this method i get an unsuported Zone ID Exception because they build TimeZone with custom IDs – Woody Jun 06 '17 at 19:27
  • And if i change the ID to put like "GMT" the ZoneId returned do not contains daylight saving time infos – Woody Jun 06 '17 at 19:28
  • Can you provide an example that's failing? In your question. – Sotirios Delimanolis Jun 06 '17 at 19:29
  • While `SimpleTimeZone` offers a way to create your own homegrown time zones, I didn’t find a way to create one’s homegrown `ZoneId` objects (one may also ask what sense it would make). It would be a bit peculiar if the latter was possible through the `TimeZone.toZoneId()` backdoor (and in no other way). So maybe it isn’t? – Ole V.V. Jun 07 '17 at 10:36
  • 1
    Your parameter values specified in the constructor call to `SimpleTimeZone` don't seem to obey the contract of that class. Please read the class description again. Your code does not look correct. Usually at least one of the parameters you have set to `1` should be negative or zero. And setting the time-parameter (time of day in ms) to `1` is at least to say strange. Once you have clarified your parameter values then you can go to the next step to construct a special instance of `ZoneRules` (which is a highly complex step). – Meno Hochschild Jun 07 '17 at 11:53
  • Take a look at the [`ZoneId` javadoc](https://docs.oracle.com/javase/8/docs/api/java/time/ZoneId.html#of-java.lang.String-): *If the zone ID equals **'GMT'**, 'UTC' or 'UT' then the result is a ZoneId with the same ID and rules equivalent to ZoneOffset.UTC*. And `ZoneOffset.UTC` has no DST rules. –  Jun 07 '17 at 12:20
  • What exactly are you trying to do? Create a custom `ZoneId` with specific DST rules? Don't these rules fit in any of the existing timezones? –  Jun 07 '17 at 14:05

3 Answers3

5

First of all, when you do:

SimpleTimeZone stz = new SimpleTimeZone(2 * 60 * 60 * 1000, "GMT", Calendar.JANUARY, 1, 1, 1, Calendar.FEBRUARY, 1, 1, 1, 1 * 60 * 60 * 1000);

You're creating a timezone with ID equals to "GMT". When you call toZoneId(), it just calls ZoneId.of("GMT") (it uses the same ID as parameter, as already told in @Ole V.V.'s answer). And then ZoneId class loads whatever Daylight Saving infos are configured in the JVM (it doesn't keep the same rules from the original SimpleTimeZone object).

And according to ZoneId javadoc: If the zone ID equals 'GMT', 'UTC' or 'UT' then the result is a ZoneId with the same ID and rules equivalent to ZoneOffset.UTC. And ZoneOffset.UTC has no DST rules at all.

So, if you want to have a ZoneId instance with the same DST rules, you'll have to create them by hand (I didn't know it was possible, but it actually is, check below).


Your DST rules

Looking at SimpleTimeZone javadoc, the instance you created has the following rules (according to my tests):

  • standard offset is +02:00 (2 hours ahead UTC/GMT)
  • DST starts at the first Sunday of January (take a look at the javadoc for more details), 1 millisecond after midnight (you passed 1 as start and end times)
  • when in DST, offset changes to +03:00
  • DST ends at the first Sunday of February, 1 millisecond after midnight (then offset gets back to +02:00)

Actually, according to javadoc, you should've passed a negative number in dayOfWeek parameters to work this way, so the timezone should be created like this:

stz = new SimpleTimeZone(2 * 60 * 60 * 1000, "GMT", Calendar.JANUARY, 1, -Calendar.SUNDAY, 1, Calendar.FEBRUARY, 1, -Calendar.SUNDAY, 1, 1 * 60 * 60 * 1000);

But in my tests, both worked the same way (maybe it fixes the non-negative values). Anyway, I've made some tests just to check these rules. First I created a SimpleDateFormat with your custom timezone:

TimeZone t = TimeZone.getTimeZone("America/Sao_Paulo");
SimpleDateFormat sdf = new SimpleDateFormat("dd/MM/yyyy HH:mm:ss Z");
sdf.setTimeZone(t);

Then I tested with the boundaries dates (before start and end of DST):

// starts at 01/01/2017 (first Sunday of January)
ZonedDateTime z = ZonedDateTime.of(2017, 1, 1, 0, 0, 0, 0, ZoneOffset.ofHours(2));
// 01/01/2017 00:00:00 +0200 (not in DST yet, offset is still +02)
System.out.println(sdf.format(new Date(z.toInstant().toEpochMilli())));
// 01/01/2017 01:01:00 +0300 (DST starts, offset changed to +03)
System.out.println(sdf.format(new Date(z.plusMinutes(1).toInstant().toEpochMilli())));

// ends at 05/02/2017 (first Sunday of February)
z = ZonedDateTime.of(2017, 2, 5, 0, 0, 0, 0, ZoneOffset.ofHours(3));
// 05/02/2017 00:00:00 +0300 (in DST yet, offset is still +03)
System.out.println(sdf.format(new Date(z.toInstant().toEpochMilli())));
// 04/02/2017 23:01:00 +0200 (DST ends, offset changed to +02 - clock moves back 1 hour: from midnight to 11 PM of previous day)
System.out.println(sdf.format(new Date(z.plusMinutes(1).toInstant().toEpochMilli())));

The output is:

01/01/2017 00:00:00 +0200
01/01/2017 01:01:00 +0300
05/02/2017 00:00:00 +0300
04/02/2017 23:01:00 +0200

So, it follows the rules described above (at 01/01/2017 midnight the offset is +0200, one minute later it's in DST (offset is now +0300; the opposite occurs at 05/02 (DST ends and offset goes back to +0200)).


Create a ZoneId with the rules above

Unfortunatelly you can't extend ZoneId and ZoneOffset, and you can't also change them because both are immutable. But it's possible to create custom rules and assign them to a new ZoneId.

And it doesn't seem to have a way to directly export the rules from SimpleTimeZone to ZoneId, so you'll have to create them by hand.

First we need to create a ZoneRules, a class that contains all the rules to when and how the offset changes. In order to create it, we need to build a list of 2 classes:

  • ZoneOffsetTransition: defines a specific date for an offset change. There must be at least one to make it work (with an empty list it failed)
  • ZoneOffsetTransitionRule: defines a general rule, not bounded to a specific date (like "first Sunday of January the offset changes from X to Y"). We must have 2 rules (one for DST start, and another for DST end)

So, let's create them:

// offsets (standard and DST)
ZoneOffset standardOffset = ZoneOffset.ofHours(2);
ZoneOffset dstOffset = ZoneOffset.ofHours(3);

// you need to create at least one transition (using a date in the very past to not interfere with the transition rules)
LocalDateTime startDate = LocalDateTime.MIN;
LocalDateTime endDate = LocalDateTime.MIN.plusDays(1);
// DST transitions (date when it happens, offset before and offset after) - you need to create at least one
ZoneOffsetTransition start = ZoneOffsetTransition.of(startDate, standardOffset, dstOffset);
ZoneOffsetTransition end = ZoneOffsetTransition.of(endDate, dstOffset, standardOffset);
// create list of transitions (to be used in ZoneRules creation)
List<ZoneOffsetTransition> transitions = Arrays.asList(start, end);

// a time to represent the first millisecond after midnight
LocalTime firstMillisecond = LocalTime.of(0, 0, 0, 1000000);
// DST start rule: first Sunday of January, 1 millisecond after midnight
ZoneOffsetTransitionRule startRule = ZoneOffsetTransitionRule.of(Month.JANUARY, 1, DayOfWeek.SUNDAY, firstMillisecond, false, TimeDefinition.WALL,
    standardOffset, standardOffset, dstOffset);
// DST end rule: first Sunday of February, 1 millisecond after midnight
ZoneOffsetTransitionRule endRule = ZoneOffsetTransitionRule.of(Month.FEBRUARY, 1, DayOfWeek.SUNDAY, firstMillisecond, false, TimeDefinition.WALL,
    standardOffset, dstOffset, standardOffset);
// list of transition rules
List<ZoneOffsetTransitionRule> transitionRules = Arrays.asList(startRule, endRule);

// create the ZoneRules instance (it'll be set on the timezone)
ZoneRules rules = ZoneRules.of(start.getOffsetAfter(), end.getOffsetAfter(), transitions, transitions, transitionRules);

I couldn't create a ZoneOffsetTransition that starts at the first millisecond after midnight (they actually start at midnight exactly), because the fraction of seconds must be zero (if it's not, ZoneOffsetTransition.of() throws an exception). So, I decided to set a date in the past (LocalDateTime.MIN) to not interfere with the rules.

But the ZoneOffsetTransitionRule instances works exactly like expected (DST starts and ends 1 millisecond after midnight, just like the SimpleTimeZone instance).

Now we must set this ZoneRules to a timezone. As I said, ZoneId can't be extended (the constructor is not public) and neither does ZoneOffset (it's a final class). I initially thought that the only way to set the rules was to create an instance and set it using reflection, but actually the API provides a way to create custom ZoneId's by extending the java.time.zone.ZoneRulesProvider class:

// new provider for my custom zone id's
public class CustomZoneRulesProvider extends ZoneRulesProvider {

    @Override
    protected Set<String> provideZoneIds() {
        // returns only one ID
        return Collections.singleton("MyNewTimezone");
    }

    @Override
    protected ZoneRules provideRules(String zoneId, boolean forCaching) {
        // returns the ZoneRules for the custom timezone
        if ("MyNewTimezone".equals(zoneId)) {
            ZoneRules rules = // create the ZoneRules as above
            return rules;
        }
        return null;
    }

    // returns a map with the ZoneRules, check javadoc for more details
    @Override
    protected NavigableMap<String, ZoneRules> provideVersions(String zoneId) {
        TreeMap<String, ZoneRules> map = new TreeMap<>();
        ZoneRules rules = getRules(zoneId, false);
        if (rules != null) {
            map.put(zoneId, rules);
        }
        return map;
    }
}

Please keep in mind that you shouldn't set the ID to "GMT", "UTC", or any valid ID (you can check all existent IDs with ZoneId.getAvailableZoneIds()). "GMT" and "UTC" are special names used internally by the API and it can lead to unexpected behaviour. So choose a name that doesn't exist - I've chosen MyNewTimezone (without spaces otherwise it'll fail because ZoneRegion throws an exception if there's a space in the name).

Let's test this new timezone. The new class must be registered using the ZoneRulesProvider.registerProvider method:

// register the new zonerules provider
ZoneRulesProvider.registerProvider(new CustomZoneRulesProvider());
// create my custom zone
ZoneId customZone = ZoneId.of("MyNewTimezone");

DateTimeFormatter fmt = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss Z");
// starts at 01/01/2017 (first Sunday of January)
ZonedDateTime z = ZonedDateTime.of(2017, 1, 1, 0, 0, 0, 0, customZone);
// 01/01/2017 00:00:00 +0200 (not in DST yet, offset is still +02)
System.out.println(z.format(fmt));
// 01/01/2017 01:01:00 +0300 (DST starts, offset changed to +03)
System.out.println(z.plusMinutes(1).format(fmt));

// ends at 05/02/2017 (first Sunday of February)
z = ZonedDateTime.of(2017, 2, 5, 0, 0, 0, 0, customZone);
// 05/02/2017 00:00:00 +0300 (in DST yet, offset is still +03)
System.out.println(z.format(fmt));
// 04/02/2017 23:01:00 +0200 (DST ends, offset changed to +02 - clock moves back 1 hour: from midnight to 11 PM of previous day)
System.out.println(z.plusMinutes(1).format(fmt));

The output is the same (so the rules are the same used by the SimpleTimeZone):

01/01/2017 00:00:00 +0200
01/01/2017 01:01:00 +0300
05/02/2017 00:00:00 +0300
04/02/2017 23:01:00 +0200


Notes:

  • The CustomZoneRulesProvider creates just one new ZoneId, but of course you can extend it to create more. Check the javadoc for more details about how to correct implement your own rules provider.
  • You must check exactly what are the rules from your custom timezones before creating the ZoneRules. One way is to use SimpleTimeZone.toString() method (that returns the internal state of the object) and read the javadoc to know how the parameters affect the rules.
  • I haven't tested enough cases to know if there are some specific date where the SimpleTimeZone and ZoneId rules behave in a different way. I've tested some dates in different years and it seems to be working fine.
  • 2
    I was first thinking “what should keep us from creating our own subclass of `ZoneId`?” But we will need to call the superclass constructor (implicitly or explicitly), and the sole constructor of `ZoneId` is package private (default visibility). So we’re obviously not supposed to (we would at least have to declare our own class in the same package, what a mess). – Ole V.V. Jun 07 '17 at 19:03
  • Wow what an answer ! But it seems a complete hack of java.time API – Woody Jun 07 '17 at 21:28
  • @Woody I've updated the answer - I found a way to create a custom timezone **without using reflection**. So it's not a hack anymore, because **it's using *only* the public API as designed and explained in the [javadoc](https://docs.oracle.com/javase/8/docs/api/java/time/zone/ZoneRulesProvider.html)**. I hope it solves your problem now. –  Jul 10 '17 at 12:18
  • 1
    @OleV.V. I've finally found a way to do it. The API provides a way to create our own `ZoneId`'s - indirectly (by extending `ZoneRulesProvider`), but it does. –  Jul 10 '17 at 12:20
  • @Hugo nice update but it seems that i have to preconfigure custom zoneId with rules, but our rules are dynamic so i cannot use what you suggest or am i missing something ? – Woody Jul 10 '17 at 17:30
  • @Woody Could you edit your question and provide some examples? It's not clear to me how dynamic they are. Another question: Java comes with lots of pre-existent built-in zones, does any of these zones suit for your needs? (call `ZoneId.getAvailableZoneIds()` and check if any of them already suits for your cases. I don't see how it can be possible a case where a new zone with DST rules that differ from all existing ones (actually, new zones can be created, but it doesn't seem to be your case) –  Jul 10 '17 at 17:58
  • @Woody Anyway, you can adapt the `CustomZoneRulesProvider` above to create dynamic zone rules as well (not sure how to do it though - it's not clear to me how this dynamism is, as stated in my previous comment) –  Jul 10 '17 at 18:05
1

I don’t think this is possible. One may also ask what sense it would make. If a time zone is not in the Olson database, it is hardly used in the real world, so what good use could it have in your program? I understand, of course, the case where your legacy program creates a SimpleTimeZone that does represent a time zone used in the wild, but just gives it an incorrect ID so that TimeZone.toZoneId() cannot make the correct translation.

I checked the source code of TimeZone.toZoneId(). It relies solely on the value obtained from the getID method. It does not look at offset or zone rules. And importantly, SimpleTimeZone does not override the method. So it appears that if your SimpleTimeZone has an known ID (including the frowned-upon abbreviations like EST or ACT), you will get the correct ZoneId. Otherwise you will not.

So I suppose your best bet is to find out what is the correct time zone ID for the time zone that your legacy code is trying to give you, and then get your ZoneId from ZoneId.of(String). Or slightly nicer, build your own map of aliases containing the ID or IDs from your legacy code and the corresponding modern ID/s, and then use ZoneId.of(String, Map<String,String>).

One possible attempt at automating the translation would be to iterate through the available time zones (which you obtain through TimeZone.getAvailableIDs() and TImeZone.getTimeZone(String)) and compare with each using hasSameRules(). Then create your ZoneId from the ID string or the TimeZone obtained from it.

Sorry, this was not the answer you were hoping for.

Ole V.V.
  • 81,772
  • 15
  • 137
  • 161
  • I found a way to solve my problem withtout setting the good IDs in the system. Actually it could have been impossible to find the matching ID because we are offering full customization of daylight saving times and gmt offsets – Woody Aug 08 '17 at 15:12
1

Here the solution i prefer because it's simple, but you need a Date and a TimeZone as parameters to retrievethe the ZoneId

private ZoneId getZoneOffsetFor(final Date date, final TimeZone timeZone){
  int offsetInMillis = getOffsetInMillis(date, timeZone);
  return ZoneOffset.ofTotalSeconds( offsetInMillis / 1000 );
}

private int getOffsetInMillis(final Date date, final TimeZone timeZone){
  int offsetInMillis = timeZone.getRawOffset();
  if(timeZone.inDaylightTime(date)){
     offsetInMillis += timeZone.getDSTSavings();
  }
  return offsetInMillis;
}
Woody
  • 809
  • 8
  • 21
  • 2
    You can achieve the same result of `getZoneOffsetFor` with `timeZone.toZoneId().getRules().getOffset(date.toInstant())`. And you can use this same offset to set the `clonedTimeZone` ID: `clonedTimeZone.setID("GMT" + zoneOffset.getTotalSeconds() / 3600)`. But the resulting ID in your code is `GMTGMT+xxx`, (GMT twice), is this correct? And you don't use `clonedTimeZone` for anything. Anyway, I'm glad you found a solution, because it wasn't clear to me that you wanted to do that. –  Aug 09 '17 at 16:39
  • Thank you, indeed the clonedTimeZone is not needed – Woody Aug 09 '17 at 17:49