• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.libcore.timezone.tzlookup.zonetree;
17 
18 import com.android.libcore.timezone.countryzones.proto.CountryZonesFile;
19 import com.android.libcore.timezone.countryzones.proto.CountryZonesFile.Country;
20 import com.android.libcore.timezone.tzlookup.zonetree.ZoneOffsetPeriod.ZonePeriodsKey;
21 import com.ibm.icu.text.TimeZoneNames;
22 import com.ibm.icu.util.BasicTimeZone;
23 import com.ibm.icu.util.TimeZone;
24 import com.ibm.icu.util.ULocale;
25 
26 import java.io.FileWriter;
27 import java.io.IOException;
28 import java.time.Duration;
29 import java.time.Instant;
30 import java.util.ArrayList;
31 import java.util.HashMap;
32 import java.util.List;
33 import java.util.Map;
34 import java.util.Objects;
35 
36 import static java.util.stream.Collectors.toList;
37 
38 /**
39  * A tree that holds all the zones for a country and records how they relate to each other over
40  * time.
41  */
42 public final class CountryZoneTree {
43 
44     /** A Visitor for visiting {@link ZoneNode} trees. */
45     private interface ZoneNodeVisitor extends TreeNode.Visitor<ZoneNode> {}
46 
47     /** A specialist node for zone trees. */
48     private static class ZoneNode extends TreeNode<ZoneNode> {
49 
50         private final int periodOffset;
51         private final List<ZoneInfo> zoneInfos;
52 
53         private int periodCount;
54         private ZoneInfo primaryZoneInfo;
55         private boolean priorityClash;
56 
ZoneNode(String id, List<ZoneInfo> zoneInfos, int periodOffset, int periodCount)57         ZoneNode(String id, List<ZoneInfo> zoneInfos, int periodOffset, int periodCount) {
58             super(id);
59             this.periodOffset = periodOffset;
60             this.zoneInfos = zoneInfos;
61             this.periodCount = periodCount;
62             initNodePriority();
63         }
64 
initNodePriority()65         private void initNodePriority() {
66             ZoneInfo priorityCandidate = null;
67             int priorityCount = 0;
68             for (ZoneInfo zoneInfo : zoneInfos) {
69                 if (priorityCandidate == null
70                         || priorityCandidate.getPriority() < zoneInfo.getPriority()) {
71                     priorityCandidate = zoneInfo;
72                     priorityCount = 1;
73                 } else if (priorityCandidate.getPriority() == zoneInfo.getPriority()) {
74                     priorityCount++;
75                 }
76             }
77             primaryZoneInfo = priorityCandidate;
78             // If more than one ZoneInfo has the same priority as the primaryZoneInfo then we
79             // can't know which one is actually the primary.
80             priorityClash = priorityCount > 1;
81         }
82 
getPrimaryZoneInfo()83         ZoneInfo getPrimaryZoneInfo() {
84             if (priorityClash) {
85                 throw new IllegalStateException("No primary zone for " + getId()
86                         + ": priority clash (between" + getZoneInfosString() + ")");
87             }
88             return primaryZoneInfo;
89         }
90 
91         /** {@code true} if multiple zones have the same priority. */
hasPriorityClash()92         boolean hasPriorityClash() {
93             return priorityClash;
94         }
95 
getZoneInfos()96         List<ZoneInfo> getZoneInfos() {
97             return zoneInfos;
98         }
99 
getZoneInfosString()100         String getZoneInfosString() {
101             return zoneInfos.stream()
102                     .map(z -> z.getZoneId() + "(" + z.getPriority() + ")")
103                     .collect(toList()).toString();
104         }
105 
getStartInstant()106         Instant getStartInstant() {
107             int offset = periodOffset + periodCount - 1;
108             int index = primaryZoneInfo.getZoneOffsetPeriodCount() - offset;
109             return primaryZoneInfo.getZoneOffsetPeriod(index).getStartInstant();
110         }
111 
getEndInstant()112         Instant getEndInstant() {
113             int index = primaryZoneInfo.getZoneOffsetPeriodCount() - periodOffset;
114             return primaryZoneInfo.getZoneOffsetPeriod(index).getEndInstant();
115         }
116 
adjustPeriodCount(int adjustment)117         void adjustPeriodCount(int adjustment) {
118             periodCount += adjustment;
119         }
120 
getPeriodOffset()121         int getPeriodOffset() {
122             return periodOffset;
123         }
124 
getPeriodCount()125         int getPeriodCount() {
126             return periodCount;
127         }
128     }
129 
130     private final String countryIso;
131 
132     private final ZoneNode root;
133     private final Instant startInclusive;
134     private final Instant endExclusive;
135 
CountryZoneTree( String countryIso, ZoneNode root, Instant startInclusive, Instant endExclusive)136     private CountryZoneTree(
137             String countryIso, ZoneNode root, Instant startInclusive, Instant endExclusive) {
138         this.countryIso = countryIso;
139         this.root = root;
140         this.startInclusive = startInclusive;
141         this.endExclusive = endExclusive;
142     }
143 
144     /**
145      * Creates a tree for the time zones for a country.
146      */
create( Country country, Instant startInclusive, Instant endExclusive)147     public static CountryZoneTree create(
148             Country country, Instant startInclusive, Instant endExclusive) {
149         return create(country, startInclusive, endExclusive, true /* compress */);
150     }
151 
152     /**
153      * Creates a tree for the time zones for a country which can optionally be compressed to remove
154      * uninteresting nodes (which makes it easier for visualization).
155      */
create( Country country, Instant startInclusive, Instant endExclusive, boolean compress)156     public static CountryZoneTree create(
157             Country country, Instant startInclusive, Instant endExclusive, boolean compress) {
158 
159         // We use the US English names for detecting time zone name clashes.
160         TimeZoneNames timeZoneNames = TimeZoneNames.getInstance(ULocale.US);
161         List<CountryZonesFile.TimeZoneMapping> timeZoneMappings = country.getTimeZoneMappingsList();
162 
163         // Create ZoneInfo objects for every time zone for the time range
164         // startInclusive -> endExclusive.
165         List<ZoneInfo> zoneInfos = new ArrayList<>();
166         for (CountryZonesFile.TimeZoneMapping timeZoneMapping : timeZoneMappings) {
167             int priority = timeZoneMapping.getPriority();
168             BasicTimeZone basicTimeZone = getBasicTimeZone(timeZoneMapping.getId());
169             ZoneInfo zoneInfo = ZoneInfo.create(
170                     timeZoneNames, basicTimeZone, priority, startInclusive, endExclusive);
171             zoneInfos.add(zoneInfo);
172         }
173 
174         // Add additional periods to the zone infos to help merging.
175         addExtraPeriodSplits(timeZoneNames, zoneInfos);
176 
177         // The algorithm constructs a tree. The root of the tree contains all ZoneInfos, and at each
178         // node the ZoneInfos can be split into subsets.
179         return create(country.getIsoCode(), zoneInfos, startInclusive, endExclusive, compress);
180     }
181 
182     /**
183      * Inserts artificial periods to the supplied {@link ZoneInfo}s to enable them to be merged
184      * more easily. The artificial periods are created by splitting existing periods into multiple
185      * sub-periods.
186      *
187      * <p>For example, some time zones do not use DST but will have changed offset or decided to
188      * stop observing DST at different points in time. To find them we introduce artificial periods
189      * as needed to make it easy to align and merge those zones.
190      *
191      * <ul>
192      *     <li>Zone A moved to X hours offset from UTC in 1985 and has stayed there</li>
193      *     <li>Zone B moved to X hours offset from UTC in 1984 and has stayed there</li>
194      * </ul>
195      *
196      * <p>The simple period matching algorithm will not detect that they can be merged after 1985
197      * because their respective periods using X hours offset from UTC have different start times.
198      * The solution is to split Zone A into two periods: 1984 -> 1985, and 1985+. Then the
199      * algorithm will find the match because both Zone A and Zone B have a period from 1985+ with
200      * the same zone properties.
201      */
addExtraPeriodSplits( TimeZoneNames timeZoneNames, List<ZoneInfo> zoneInfos)202     private static void addExtraPeriodSplits(
203             TimeZoneNames timeZoneNames, List<ZoneInfo> zoneInfos) {
204 
205         // This algorithm works backwards through all the zone info periods by incrementing an
206         // "offset from the end". It looks for periods that "match". In this case "match" means
207         // "have the same zone properties, or *could* have the same zone properties if it were not
208         // for different start times". If two or more matching zone offset periods are found with
209         // different start times it splits the longer ones in two so that periods then exist with
210         // the same offset with the same start and end times. It keeps going backwards through the
211         // periods for all the zoneinfos until no two periods with the same offset match.
212 
213         // True if any of the zones have the matching properties. When false this is the stopping
214         // condition for the loop that steps backwards through the zone offset periods.
215         boolean anyZonesMatch;
216 
217         // The offset into the zone offset periods. Since we work in reverse (from the end of time),
218         // offset = 1 means "the last period" offset = 2 means "the last but one period", etc.
219         // We initialize at 0; it is incremented at the start of the do-while loop.
220         int offsetFromEnd = 0;
221 
222         // The offsetFromEnd loop: increments offsetFromEnd and processes zone offset periods found
223         // at that offset.
224         do {
225             offsetFromEnd += 1;
226 
227             // Reset our stopping condition. The offsetFromEnd loop will stop if this stays false.
228             anyZonesMatch = false;
229 
230             // Keep track of which zoneinfos have been processed for the current offsetFromEnd so
231             // we don't process them again.
232             boolean[] processed = new boolean[zoneInfos.size()];
233 
234             // The splitting loop: processes groups of zoneinfos with matching periods at offset and
235             // splits the matching periods as needed.
236             for (int i = 0; i < zoneInfos.size(); i++) {
237                 if (processed[i]) {
238                     // zoneinfo[i] has already been processed by a prior round of the splitting
239                     // loop. Skip.
240                     continue;
241                 }
242                 processed[i] = true;
243 
244                 // Get zoneinfo[i] so we can use its zone properties to find all matching periods.
245                 ZoneInfo iZoneInfo = zoneInfos.get(i);
246                 ZoneOffsetPeriod iPeriod =
247                         getOffsetPeriodAtOffsetFromEndOrNull(iZoneInfo, offsetFromEnd);
248                 if (iPeriod == null) {
249                     // We've run out of periods for this zoneinfo. Skip.
250                     continue;
251                 }
252 
253                 // Pass 1: Find all zoneinfos that have a period at offsetFromEnd that matches the
254                 // same period at offsetFromEnd from zoneinfo[i]. Also work out the instant that
255                 // they would need to be split at.
256                 boolean[] matchAtOffsetFromEnd = new boolean[zoneInfos.size()];
257                 // zoneinfo[i] matches itself.
258                 matchAtOffsetFromEnd[i] = true;
259 
260                 Instant toSplitInstant = iPeriod.getStartInstant();
261                 for (int j = i + 1; j < zoneInfos.size(); j++) {
262                     if (processed[j]) {
263                         // Don't bother to look any other zoneinfos that have previously been
264                         // processed.
265                         continue;
266                     }
267 
268                     ZoneInfo jZoneInfo = zoneInfos.get(j);
269                     ZoneOffsetPeriod jPeriod =
270                             getOffsetPeriodAtOffsetFromEndOrNull(jZoneInfo, offsetFromEnd);
271                     if (jPeriod == null) {
272                         // We've run out of periods for this zoneinfo. Skip and make sure we don't
273                         // look at it again.
274                         processed[j] = true;
275                         continue;
276                     }
277                     if (isMatch(iPeriod, jPeriod)) {
278                         // At least one pair of zones have similar properties so we get to loop
279                         // around the outer loop at least once more.
280                         anyZonesMatch = true;
281 
282                         // Mark zoneinfo[j] as being a match for zoneinfo[i] at offset.
283                         matchAtOffsetFromEnd[j] = true;
284                         if (jPeriod.getStartInstant().isAfter(toSplitInstant)) {
285                             // Keep track of the max start instant for pass 2.
286                             toSplitInstant = jPeriod.getStartInstant();
287                         }
288                     }
289                 }
290 
291                 // Pass 2: Split all the periods for the matching zonesinfos at toSplitInstant
292                 // (if needed).
293                 for (int j = i; j < zoneInfos.size(); j++) {
294                     if (!matchAtOffsetFromEnd[j]) {
295                         continue;
296                     }
297                     ZoneInfo jZoneInfo = zoneInfos.get(j);
298                     int jIndex = jZoneInfo.getZoneOffsetPeriodCount() - offsetFromEnd;
299                     ZoneOffsetPeriod jPeriod = jZoneInfo.getZoneOffsetPeriod(jIndex);
300                     if (!jPeriod.getStartInstant().equals(toSplitInstant)) {
301                         ZoneInfo.splitZoneOffsetPeriodAtTime(
302                                 timeZoneNames, jZoneInfo, jIndex, toSplitInstant);
303                     }
304                     processed[j] = true;
305                 }
306             }
307         } while (anyZonesMatch);
308     }
309 
310     /**
311      * Returns the {@link ZoneOffsetPeriod} with the specified offset from the end, or null if there
312      * isn't one.
313      */
getOffsetPeriodAtOffsetFromEndOrNull( ZoneInfo zoneInfo, int offsetFromEnd)314     private static ZoneOffsetPeriod getOffsetPeriodAtOffsetFromEndOrNull(
315             ZoneInfo zoneInfo, int offsetFromEnd) {
316         int index = zoneInfo.getZoneOffsetPeriodCount() - offsetFromEnd;
317         if (index < 0) {
318             return null;
319         }
320         return zoneInfo.getZoneOffsetPeriod(index);
321     }
322 
323     /**
324      * Returns true if the one of the two {@link ZoneOffsetPeriod}s could be split and may be
325      * identical to the other. The name is ignored since to know the name requires a time. If we
326      * split and the name turns out not to be the same then it's ok: the different name will ensure
327      * the periods will not actually be merged.
328      */
isMatch(ZoneOffsetPeriod a, ZoneOffsetPeriod b)329     private static boolean isMatch(ZoneOffsetPeriod a, ZoneOffsetPeriod b) {
330         return a.getEndInstant().equals(b.getEndInstant())
331                 && a.getDstOffsetMillis() == b.getDstOffsetMillis()
332                 && a.getRawOffsetMillis() == b.getRawOffsetMillis();
333     }
334 
create(String countryIso, List<ZoneInfo> zoneInfos, Instant startInclusive, Instant endExclusive, boolean compress)335     private static CountryZoneTree create(String countryIso, List<ZoneInfo> zoneInfos,
336             Instant startInclusive, Instant endExclusive, boolean compress) {
337         // Create a root node with all the information needed to grow the whole tree.
338         ZoneNode root = new ZoneNode("0", zoneInfos, 0, 0);
339 
340         // We call growTree() to build all the branches and leaves from the root.
341         growTree(root);
342 
343         // Now we compress the tree to remove unnecessary nodes if we have been asked to do so.
344         if (compress) {
345             compressTree(root);
346         }
347 
348         // Wrap the root and return.
349         return new CountryZoneTree(countryIso, root, startInclusive, endExclusive);
350     }
351 
352     /**
353      * Grows the zone tree from the root.
354      *
355      * <p>After this step, we have a tree represents a forest. The root node is just a convenience
356      * for constructing and anchoring the trees in the forest. Below the root, each node
357      * represents a single period of time where all the zones associated with the node agreed on
358      * what the local time was and what it was called. The tree grows from the future (the root)
359      * into the past (the leaves). If a node has a single child it means that the previous
360      * period (the child) also had every zone in agreement. If a node has zero children it means
361      * there are no more periods in the past to investigate. If a node has multiple children it
362      * means that the zones disagreed in the past. Looking at a node with multiple children in
363      * reverse, from the children to a parent (i.e. going forward in time), it means that
364      * several zones that previously disagreed were standardized to be the same. The tzdb ID
365      * exists forever, but if zones have standardized it means that fewer zones are needed to
366      * represent all possible local times in a given country.
367      */
growTree(ZoneNode root)368     private static void growTree(ZoneNode root) {
369         root.visitSelfThenChildrenRecursive((ZoneNodeVisitor) currentNode -> {
370             // Increase the period offset by one so that the child will be for one period further
371             // back.
372             int newPeriodOffset = currentNode.getPeriodOffset() + 1;
373 
374             // Split the zoneinfo set into new sets for the new depth.
375             List<ZoneInfo> zoneInfosToSplit = currentNode.getZoneInfos();
376 
377             // Generate all the child sets.
378             Map<ZonePeriodsKey, List<ZoneInfo>> newSetsMap = new HashMap<>();
379             for (ZoneInfo zoneInfo : zoneInfosToSplit) {
380                 int periodStartIndex = zoneInfo.getZoneOffsetPeriodCount() - newPeriodOffset;
381                 if (periodStartIndex < 0) {
382                     // We've run out of ZoneOffsetPeriods. We could declare this a leaf node at this
383                     // point but we continue for safety to allow the childZoneInfoCount check below.
384                     continue;
385                 }
386                 // Create a zone key for the zoneInfo. We only need to look at one period each time
387                 // as we know all periods after this point have to agree (otherwise they wouldn't
388                 // have been lumped together in a single node).
389                 ZonePeriodsKey zoneKey =
390                         zoneInfo.createZonePeriodsKey(periodStartIndex, periodStartIndex + 1);
391                 List<ZoneInfo> identicalTimeZones =
392                         newSetsMap.computeIfAbsent(zoneKey, k -> new ArrayList<>());
393                 identicalTimeZones.add(zoneInfo);
394             }
395 
396             // Construct any child nodes.
397             int childZoneInfoCount = 0;
398             int i = 1;
399             for (Map.Entry<ZonePeriodsKey, List<ZoneInfo>> newSetEntry : newSetsMap.entrySet()) {
400                 List<ZoneInfo> newSet = newSetEntry.getValue();
401                 childZoneInfoCount += newSet.size();
402                 // The child ID is just the {parent ID}.{child number} so we create an easy-to-debug
403                 // address.
404                 String childId = currentNode.getId() + "." + i;
405                 ZoneNode e = new ZoneNode(childId, newSet, newPeriodOffset, 1 /* periodCount */);
406                 currentNode.addChild(e);
407                 i++;
408             }
409 
410             // Assertion: a node should either have no nodes (be a leaf) or all zones should have
411             // been split between the children.
412             if (childZoneInfoCount != 0 && childZoneInfoCount != zoneInfosToSplit.size()) {
413                 // This implies some kind of data issue.
414                 throw new IllegalStateException();
415             }
416         });
417     }
418 
419     /**
420      * Removes uninteresting nodes from the tree by merging them with their children where possible.
421      * Uninteresting nodes are those that have a single child; having a single child implies the
422      * node and its child have the same offsets and other information (they're just for an earlier
423      * period). The resulting merged node has the same zones and depthInTree but a larger period
424      * count.
425      */
compressTree(ZoneNode root)426     private static void compressTree(ZoneNode root) {
427         class CompressionVisitor implements ZoneNodeVisitor {
428 
429             @Override
430             public void visit(ZoneNode node) {
431                 if (node.isRoot()) {
432                     // Ignore the root.
433                     return;
434                 }
435 
436                 // If there's one child then we can compress it into node.
437                 if (node.getChildrenCount() == 1) {
438                     ZoneNode child = node.getChildren().get(0);
439 
440                     // Remove the child from node.
441                     node.removeChild(child);
442 
443                     // The child may also have descendants with a single child, so handle those too.
444                     int periodCountAdjustment = child.getPeriodCount();
445                     ZoneNode descendant = child;
446                     while (descendant.getChildrenCount() == 1) {
447                         descendant = descendant.getChildren().get(0);
448                         periodCountAdjustment += descendant.getPeriodCount();
449                     }
450 
451                     // Remove the children from descendant and add them to node.
452                     for (ZoneNode newChild : descendant.getChildren()) {
453                         descendant.removeChild(newChild);
454                         node.addChild(newChild);
455                     }
456 
457                     // Add the removed descendant's period count to node.
458                     node.adjustPeriodCount(periodCountAdjustment);
459                 }
460             }
461         }
462         root.visitSelfThenChildrenRecursive(new CompressionVisitor());
463     }
464 
465     /** Validates the tree has no nodes with priority clashes, returns a list of issues. */
validateNoPriorityClashes()466     public List<String> validateNoPriorityClashes() {
467         class ValidationVisitor implements ZoneNodeVisitor {
468             private final List<String> issues = new ArrayList<>();
469 
470             @Override
471             public void visit(ZoneNode node) {
472                 if (node.isRoot()) {
473                     // Ignore the root, it's not a "real" node and will usually clash in countries
474                     // where there's more than one zone.
475                     return;
476                 }
477 
478                 if (node.hasPriorityClash()) {
479                     String issue = node.getZoneInfosString();
480                     issues.add(issue);
481                 }
482             }
483 
484             public List<String> getIssues() {
485                 return issues;
486             }
487         }
488 
489         ValidationVisitor visitor = new ValidationVisitor();
490         root.visitSelfThenChildrenRecursive(visitor);
491         return visitor.getIssues();
492     }
493 
494     /**
495      * Creates a {@link CountryZoneUsage} object from the tree.
496      */
calculateCountryZoneUsage(Instant notAfterCutOff)497     public CountryZoneUsage calculateCountryZoneUsage(Instant notAfterCutOff) {
498         class CountryZoneVisibilityVisitor implements ZoneNodeVisitor {
499             private final CountryZoneUsage zoneUsage = new CountryZoneUsage(countryIso);
500 
501             @Override
502             public void visit(ZoneNode node) {
503                 // We ignore the root.
504                 if (node.isRoot()) {
505                     return;
506                 }
507 
508                 if (node.hasPriorityClash()) {
509                     throw new IllegalStateException(
510                             "Cannot calculate zone usage with priority clashes present");
511                 }
512 
513                 Instant endInstant = node.getEndInstant();
514                 String replacementTimeZoneId = getReplacementTimeZoneIdOrNull(node);
515                 if (!node.isLeaf()) {
516                     ZoneInfo primaryZone = node.getPrimaryZoneInfo();
517                     addZoneEntryIfMissing(endInstant, replacementTimeZoneId, primaryZone);
518                 } else {
519                     // In some rare cases (e.g. Canada: Swift_Current and Creston) zones have agreed
520                     // completely since 1970 so some leaves may have multiple zones. So, attempt to
521                     // add all zones for leaves, not just the primary.
522                     for (ZoneInfo zoneInfo : node.getZoneInfos()) {
523                         addZoneEntryIfMissing(endInstant, replacementTimeZoneId, zoneInfo);
524                     }
525                 }
526             }
527 
528             /**
529              * Takes a {@link ZoneNode} and if the parent node has a different primary zone ID, then
530              * this method returns that zone ID. {@code null} is returned otherwise.
531              */
532             private String getReplacementTimeZoneIdOrNull(ZoneNode node) {
533                 if (node.isRoot()) {
534                     // There is no parent node, so there can be no replacement ID.
535                     return null;
536                 }
537 
538                 String zoneId = node.primaryZoneInfo.getZoneId();
539                 String replacementId = node.getParent().primaryZoneInfo.getZoneId();
540                 if (Objects.equals(zoneId, replacementId)) {
541                     // Often, the parent node will have the same primary zone ID. A zone ID cannot
542                     // replace itself. Since we're traversing the tree "preorder" this is fine - if
543                     // there is a replacement later in time it will already have been found.
544                     return null;
545                 }
546                 return replacementId;
547             }
548 
549             private void addZoneEntryIfMissing(
550                     Instant endInstant, String replacementTimeZoneId, ZoneInfo zoneInfo) {
551                 String zoneId = zoneInfo.getZoneId();
552 
553                 if (Objects.equals(zoneId, replacementTimeZoneId)) {
554                     throw new IllegalStateException(zoneId + " cannot replace itself. Cycle!");
555                 }
556 
557                 if (!notAfterCutOff.isAfter(endInstant)) {
558                     // notAfterCutOff <= endInstant
559                     endInstant = null;
560                     replacementTimeZoneId = null;
561                 }
562                 if (!zoneUsage.hasEntry(zoneId)) {
563                     zoneUsage.addEntry(zoneId, endInstant, replacementTimeZoneId);
564                 }
565             }
566 
567             private CountryZoneUsage getCountryZoneUsage() {
568                 return zoneUsage;
569             }
570         }
571 
572         CountryZoneVisibilityVisitor visitor = new CountryZoneVisibilityVisitor();
573         root.visitSelfThenChildrenRecursive(visitor);
574         return visitor.getCountryZoneUsage();
575     }
576 
577     /**
578      * Creates a Graphviz file for visualizing the tree.
579      */
createGraphvizFile(String outputFile)580     public void createGraphvizFile(String outputFile) throws IOException {
581         class DotFileVisitor implements ZoneNodeVisitor {
582             private StringBuilder graphStringBuilder = new StringBuilder();
583 
584             @Override
585             public void visit(ZoneNode node) {
586                 if (node.isRoot()) {
587                     // Don't draw the root - make the tree look like a forest.
588                     return;
589                 }
590 
591                 String nodeName = enquote(node.getId());
592 
593                 // Draw the node.
594                 Instant startInstant = node.getStartInstant();
595                 Instant endInstant = node.getEndInstant();
596                 boolean priorityClash = node.hasPriorityClash();
597 
598                 String fromTimestamp = startInstant.toString();
599                 String toTimestamp = endInstant.toString();
600                 String optionalColor = priorityClash ? ",color=\"red\"" : "";
601                 String label = node.getZoneInfosString()
602                         + "\nFrom=" + fromTimestamp + " to " + toTimestamp
603                         + "\nPeriod count=" + node.getPeriodCount();
604                 if (node.getPeriodCount() == 1) {
605                     ZoneInfo arbitraryZoneInfo = node.getZoneInfos().get(0);
606                     int periodIndex =
607                             arbitraryZoneInfo.getZoneOffsetPeriodCount() - node.getPeriodOffset();
608                     ZoneOffsetPeriod zoneOffsetPeriod =
609                             arbitraryZoneInfo.getZoneOffsetPeriod(periodIndex);
610                     label += "\nrawOffset=" + durationString(zoneOffsetPeriod.getRawOffsetMillis());
611                     label += "\ndstOffset=" + durationString(zoneOffsetPeriod.getDstOffsetMillis());
612                     label += "\nname=" + zoneOffsetPeriod.getName();
613                 }
614                 writeLine(nodeName + "[label=" + enquote(label) + optionalColor + "];");
615 
616                 // Link the node to its children.
617                 for (ZoneNode child : node.getChildren()) {
618                     writeLine(nodeName + " -> " + enquote(child.getId()) + ";");
619                 }
620             }
621 
622             private String durationString(int durationMillis) {
623                 return Duration.ofMillis(durationMillis).toString();
624             }
625 
626             private String enquote(String toQuote) {
627                 return "\"" + toQuote + "\"";
628             }
629 
630             private void writeLine(String s) {
631                 graphStringBuilder.append(s);
632                 graphStringBuilder.append('\n');
633             }
634         }
635 
636         DotFileVisitor dotFileVisitor = new DotFileVisitor();
637         root.visitSelfThenChildrenRecursive(dotFileVisitor);
638 
639         try (FileWriter fileWriter = new FileWriter(outputFile)) {
640             writeLine(fileWriter, "strict digraph " + countryIso + " {");
641             writeLine(fileWriter, dotFileVisitor.graphStringBuilder.toString());
642             writeLine(fileWriter, "}");
643         }
644     }
645 
writeLine(Appendable appendable, String s)646     private static void writeLine(Appendable appendable, String s) throws IOException {
647         appendable.append(s);
648         appendable.append('\n');
649     }
650 
651     /**
652      * Returns an ICU {@link BasicTimeZone} with the specified ID or throws an exception if there
653      * isn't one.
654      */
getBasicTimeZone(String zoneId)655     private static BasicTimeZone getBasicTimeZone(String zoneId) {
656         TimeZone timeZone = TimeZone.getTimeZone(zoneId);
657         if (isInvalidZone(timeZone)) {
658             throw new IllegalArgumentException(
659                     "Unknown or unexpected type for zone id: " + timeZone.getID());
660         }
661         return (BasicTimeZone) timeZone;
662     }
663 
isInvalidZone(TimeZone timeZone)664     private static boolean isInvalidZone(TimeZone timeZone) {
665         return !(timeZone instanceof BasicTimeZone)
666                 || timeZone.getID().equals(TimeZone.UNKNOWN_ZONE_ID);
667     }
668 }
669