1 /* 2 * Copyright (C) 2017 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 17 package libcore.util; 18 19 import org.xmlpull.v1.XmlPullParser; 20 import org.xmlpull.v1.XmlPullParserException; 21 import org.xmlpull.v1.XmlPullParserFactory; 22 23 import android.icu.util.TimeZone; 24 25 import java.io.FileNotFoundException; 26 import java.io.IOException; 27 import java.io.Reader; 28 import java.io.StringReader; 29 import java.nio.charset.Charset; 30 import java.nio.charset.StandardCharsets; 31 import java.nio.file.Files; 32 import java.nio.file.Path; 33 import java.nio.file.Paths; 34 import java.util.ArrayList; 35 import java.util.Arrays; 36 import java.util.Collections; 37 import java.util.HashSet; 38 import java.util.List; 39 import java.util.Locale; 40 import java.util.Set; 41 import libcore.util.CountryTimeZones.TimeZoneMapping; 42 43 /** 44 * A class that can find matching time zones by loading data from the tzlookup.xml file. 45 */ 46 public final class TimeZoneFinder { 47 48 private static final String TZLOOKUP_FILE_NAME = "tzlookup.xml"; 49 50 // Root element. e.g. <timezones ianaversion="2017b"> 51 private static final String TIMEZONES_ELEMENT = "timezones"; 52 private static final String IANA_VERSION_ATTRIBUTE = "ianaversion"; 53 54 // Country zones section. e.g. <countryzones> 55 private static final String COUNTRY_ZONES_ELEMENT = "countryzones"; 56 57 // Country data. e.g. <country code="gb" default="Europe/London" everutc="y"> 58 private static final String COUNTRY_ELEMENT = "country"; 59 private static final String COUNTRY_CODE_ATTRIBUTE = "code"; 60 private static final String DEFAULT_TIME_ZONE_ID_ATTRIBUTE = "default"; 61 private static final String EVER_USES_UTC_ATTRIBUTE = "everutc"; 62 63 // Country -> Time zone mapping. e.g. <id>ZoneId</id>, <id picker="n">ZoneId</id>, 64 // <id notafter={timestamp}>ZoneId</id> 65 // The default for the picker attribute when unspecified is "y". 66 // The notafter attribute is optional. It specifies a timestamp (time in milliseconds from Unix 67 // epoch start) after which the zone is not (effectively) in use. If unspecified the zone is in 68 // use forever. 69 private static final String ZONE_ID_ELEMENT = "id"; 70 private static final String ZONE_SHOW_IN_PICKER_ATTRIBUTE = "picker"; 71 private static final String ZONE_NOT_USED_AFTER_ATTRIBUTE = "notafter"; 72 73 private static final String TRUE_ATTRIBUTE_VALUE = "y"; 74 private static final String FALSE_ATTRIBUTE_VALUE = "n"; 75 76 private static TimeZoneFinder instance; 77 78 private final ReaderSupplier xmlSource; 79 80 // Cached field for the last country looked up. 81 private CountryTimeZones lastCountryTimeZones; 82 83 private TimeZoneFinder(ReaderSupplier xmlSource) { 84 this.xmlSource = xmlSource; 85 } 86 87 /** 88 * Obtains an instance for use when resolving time zones. This method handles using the correct 89 * file when there are several to choose from. This method never returns {@code null}. No 90 * in-depth validation is performed on the file content, see {@link #validate()}. 91 */ 92 public static TimeZoneFinder getInstance() { 93 synchronized(TimeZoneFinder.class) { 94 if (instance == null) { 95 String[] tzLookupFilePaths = 96 TimeZoneDataFiles.getTimeZoneFilePaths(TZLOOKUP_FILE_NAME); 97 instance = createInstanceWithFallback(tzLookupFilePaths[0], tzLookupFilePaths[1]); 98 } 99 } 100 return instance; 101 } 102 103 // VisibleForTesting 104 public static TimeZoneFinder createInstanceWithFallback(String... tzLookupFilePaths) { 105 IOException lastException = null; 106 for (String tzLookupFilePath : tzLookupFilePaths) { 107 try { 108 // We assume that any file in /data was validated before install, and the system 109 // file was validated before the device shipped. Therefore, we do not pay the 110 // validation cost here. 111 return createInstance(tzLookupFilePath); 112 } catch (IOException e) { 113 // There's expected to be two files, and it's normal for the first file not to 114 // exist so we don't log, but keep the lastException so we can log it if there 115 // are no valid files available. 116 if (lastException != null) { 117 e.addSuppressed(lastException); 118 } 119 lastException = e; 120 } 121 } 122 123 System.logE("No valid file found in set: " + Arrays.toString(tzLookupFilePaths) 124 + " Printing exceptions and falling back to empty map.", lastException); 125 return createInstanceForTests("<timezones><countryzones /></timezones>"); 126 } 127 128 /** 129 * Obtains an instance using a specific data file, throwing an IOException if the file does not 130 * exist or is not readable. This method never returns {@code null}. No in-depth validation is 131 * performed on the file content, see {@link #validate()}. 132 */ 133 public static TimeZoneFinder createInstance(String path) throws IOException { 134 ReaderSupplier xmlSupplier = ReaderSupplier.forFile(path, StandardCharsets.UTF_8); 135 return new TimeZoneFinder(xmlSupplier); 136 } 137 138 /** Used to create an instance using an in-memory XML String instead of a file. */ 139 // VisibleForTesting 140 public static TimeZoneFinder createInstanceForTests(String xml) { 141 return new TimeZoneFinder(ReaderSupplier.forString(xml)); 142 } 143 144 /** 145 * Parses the data file, throws an exception if it is invalid or cannot be read. 146 */ 147 public void validate() throws IOException { 148 try { 149 processXml(new TimeZonesValidator()); 150 } catch (XmlPullParserException e) { 151 throw new IOException("Parsing error", e); 152 } 153 } 154 155 /** 156 * Returns the IANA rules version associated with the data. If there is no version information 157 * or there is a problem reading the file then {@code null} is returned. 158 */ 159 public String getIanaVersion() { 160 IanaVersionExtractor ianaVersionExtractor = new IanaVersionExtractor(); 161 try { 162 processXml(ianaVersionExtractor); 163 return ianaVersionExtractor.getIanaVersion(); 164 } catch (XmlPullParserException | IOException e) { 165 return null; 166 } 167 } 168 169 /** 170 * Loads all the country <-> time zone mapping data into memory. This method can return 171 * {@code null} in the event of an error while reading the underlying data files. 172 */ 173 public CountryZonesFinder getCountryZonesFinder() { 174 CountryZonesLookupExtractor extractor = new CountryZonesLookupExtractor(); 175 try { 176 processXml(extractor); 177 178 return extractor.getCountryZonesLookup(); 179 } catch (XmlPullParserException | IOException e) { 180 System.logW("Error reading country zones ", e); 181 return null; 182 } 183 } 184 185 /** 186 * Returns a frozen ICU time zone that has / would have had the specified offset and DST value 187 * at the specified moment in the specified country. 188 * 189 * <p>In order to be considered a configured zone must match the supplied offset information. 190 * 191 * <p>Matches are considered in a well-defined order. If multiple zones match and one of them 192 * also matches the (optional) bias parameter then the bias time zone will be returned. 193 * Otherwise the first match found is returned. 194 */ 195 public TimeZone lookupTimeZoneByCountryAndOffset( 196 String countryIso, int offsetMillis, boolean isDst, long whenMillis, TimeZone bias) { 197 198 CountryTimeZones countryTimeZones = lookupCountryTimeZones(countryIso); 199 if (countryTimeZones == null) { 200 return null; 201 } 202 CountryTimeZones.OffsetResult offsetResult = 203 countryTimeZones.lookupByOffsetWithBias(offsetMillis, isDst, whenMillis, bias); 204 return offsetResult != null ? offsetResult.mTimeZone : null; 205 } 206 207 /** 208 * Returns a "default" time zone ID known to be used in the specified country. This is 209 * the time zone ID that can be used if only the country code is known and can be presumed to be 210 * the "best" choice in the absence of other information. For countries with more than one zone 211 * the time zone will not be correct for everybody. 212 * 213 * <p>If the country code is not recognized or there is an error during lookup this can return 214 * null. 215 */ 216 public String lookupDefaultTimeZoneIdByCountry(String countryIso) { 217 CountryTimeZones countryTimeZones = lookupCountryTimeZones(countryIso); 218 return countryTimeZones == null ? null : countryTimeZones.getDefaultTimeZoneId(); 219 } 220 221 /** 222 * Returns an immutable list of frozen ICU time zones known to be used in the specified country. 223 * If the country code is not recognized or there is an error during lookup this can return 224 * null. The TimeZones returned will never contain {@link TimeZone#UNKNOWN_ZONE}. This method 225 * can return an empty list in a case when the underlying data files reference only unknown 226 * zone IDs. 227 */ 228 public List<TimeZone> lookupTimeZonesByCountry(String countryIso) { 229 CountryTimeZones countryTimeZones = lookupCountryTimeZones(countryIso); 230 return countryTimeZones == null ? null : countryTimeZones.getIcuTimeZones(); 231 } 232 233 /** 234 * Returns an immutable list of time zone IDs known to be used in the specified country. 235 * If the country code is not recognized or there is an error during lookup this can return 236 * null. The IDs returned will all be valid for use with 237 * {@link java.util.TimeZone#getTimeZone(String)} and 238 * {@link android.icu.util.TimeZone#getTimeZone(String)}. This method can return an empty list 239 * in a case when the underlying data files reference only unknown zone IDs. 240 */ 241 public List<String> lookupTimeZoneIdsByCountry(String countryIso) { 242 CountryTimeZones countryTimeZones = lookupCountryTimeZones(countryIso); 243 return countryTimeZones == null 244 ? null : extractTimeZoneIds(countryTimeZones.getTimeZoneMappings()); 245 } 246 247 /** 248 * Returns a {@link CountryTimeZones} object associated with the specified country code. 249 * Caching is handled as needed. If the country code is not recognized or there is an error 250 * during lookup this method can return null. 251 */ 252 public CountryTimeZones lookupCountryTimeZones(String countryIso) { 253 synchronized (this) { 254 if (lastCountryTimeZones != null && lastCountryTimeZones.isForCountryCode(countryIso)) { 255 return lastCountryTimeZones; 256 } 257 } 258 259 SelectiveCountryTimeZonesExtractor extractor = 260 new SelectiveCountryTimeZonesExtractor(countryIso); 261 try { 262 processXml(extractor); 263 264 CountryTimeZones countryTimeZones = extractor.getValidatedCountryTimeZones(); 265 if (countryTimeZones == null) { 266 // None matched. Return the null but don't change the cached value. 267 return null; 268 } 269 270 // Update the cached value. 271 synchronized (this) { 272 lastCountryTimeZones = countryTimeZones; 273 } 274 return countryTimeZones; 275 } catch (XmlPullParserException | IOException e) { 276 System.logW("Error reading country zones ", e); 277 278 // Error - don't change the cached value. 279 return null; 280 } 281 } 282 283 /** 284 * Processes the XML, applying the {@link TimeZonesProcessor} to the <countryzones> 285 * element. Processing can terminate early if the 286 * {@link TimeZonesProcessor#processCountryZones(String, String, boolean, List, String)} returns 287 * {@link TimeZonesProcessor#HALT} or it throws an exception. 288 */ 289 private void processXml(TimeZonesProcessor processor) 290 throws XmlPullParserException, IOException { 291 try (Reader reader = xmlSource.get()) { 292 XmlPullParserFactory xmlPullParserFactory = XmlPullParserFactory.newInstance(); 293 xmlPullParserFactory.setNamespaceAware(false); 294 295 XmlPullParser parser = xmlPullParserFactory.newPullParser(); 296 parser.setInput(reader); 297 298 /* 299 * The expected XML structure is: 300 * <timezones ianaversion="2017b"> 301 * <countryzones> 302 * <country code="us" default="America/New_York"> 303 * <id>America/New_York"</id> 304 * ... 305 * <id picker="n">America/Indiana/Vincennes</id> 306 * ... 307 * <id>America/Los_Angeles</id> 308 * </country> 309 * <country code="gb" default="Europe/London"> 310 * <id>Europe/London</id> 311 * </country> 312 * </countryzones> 313 * </timezones> 314 */ 315 316 findRequiredStartTag(parser, TIMEZONES_ELEMENT); 317 318 // We do not require the ianaversion attribute be present. It is metadata that helps 319 // with versioning but is not required. 320 String ianaVersion = parser.getAttributeValue( 321 null /* namespace */, IANA_VERSION_ATTRIBUTE); 322 if (processor.processHeader(ianaVersion) == TimeZonesProcessor.HALT) { 323 return; 324 } 325 326 // There is only one expected sub-element <countryzones> in the format currently, skip 327 // over anything before it. 328 findRequiredStartTag(parser, COUNTRY_ZONES_ELEMENT); 329 330 if (processCountryZones(parser, processor) == TimeZonesProcessor.HALT) { 331 return; 332 } 333 334 // Make sure we are on the </countryzones> tag. 335 checkOnEndTag(parser, COUNTRY_ZONES_ELEMENT); 336 337 // Advance to the next tag. 338 parser.next(); 339 340 // Skip anything until </timezones>, and make sure the file is not truncated and we can 341 // find the end. 342 consumeUntilEndTag(parser, TIMEZONES_ELEMENT); 343 344 // Make sure we are on the </timezones> tag. 345 checkOnEndTag(parser, TIMEZONES_ELEMENT); 346 } 347 } 348 349 private static boolean processCountryZones(XmlPullParser parser, 350 TimeZonesProcessor processor) throws IOException, XmlPullParserException { 351 352 // Skip over any unexpected elements and process <country> elements. 353 while (findOptionalStartTag(parser, COUNTRY_ELEMENT)) { 354 if (processor == null) { 355 consumeUntilEndTag(parser, COUNTRY_ELEMENT); 356 } else { 357 String code = parser.getAttributeValue( 358 null /* namespace */, COUNTRY_CODE_ATTRIBUTE); 359 if (code == null || code.isEmpty()) { 360 throw new XmlPullParserException( 361 "Unable to find country code: " + parser.getPositionDescription()); 362 } 363 String defaultTimeZoneId = parser.getAttributeValue( 364 null /* namespace */, DEFAULT_TIME_ZONE_ID_ATTRIBUTE); 365 if (defaultTimeZoneId == null || defaultTimeZoneId.isEmpty()) { 366 throw new XmlPullParserException("Unable to find default time zone ID: " 367 + parser.getPositionDescription()); 368 } 369 Boolean everUsesUtc = parseBooleanAttribute( 370 parser, EVER_USES_UTC_ATTRIBUTE, null /* defaultValue */); 371 if (everUsesUtc == null) { 372 // There is no valid default: we require this to be specified. 373 throw new XmlPullParserException( 374 "Unable to find UTC hint attribute (" + EVER_USES_UTC_ATTRIBUTE + "): " 375 + parser.getPositionDescription()); 376 } 377 378 String debugInfo = parser.getPositionDescription(); 379 List<TimeZoneMapping> timeZoneMappings = parseTimeZoneMappings(parser); 380 boolean result = processor.processCountryZones(code, defaultTimeZoneId, everUsesUtc, 381 timeZoneMappings, debugInfo); 382 if (result == TimeZonesProcessor.HALT) { 383 return TimeZonesProcessor.HALT; 384 } 385 } 386 387 // Make sure we are on the </country> element. 388 checkOnEndTag(parser, COUNTRY_ELEMENT); 389 } 390 391 return TimeZonesProcessor.CONTINUE; 392 } 393 394 private static List<TimeZoneMapping> parseTimeZoneMappings(XmlPullParser parser) 395 throws IOException, XmlPullParserException { 396 List<TimeZoneMapping> timeZoneMappings = new ArrayList<>(); 397 398 // Skip over any unexpected elements and process <id> elements. 399 while (findOptionalStartTag(parser, ZONE_ID_ELEMENT)) { 400 // The picker attribute is optional and defaulted to true. 401 boolean showInPicker = parseBooleanAttribute( 402 parser, ZONE_SHOW_IN_PICKER_ATTRIBUTE, true /* defaultValue */); 403 Long notUsedAfter = parseLongAttribute( 404 parser, ZONE_NOT_USED_AFTER_ATTRIBUTE, null /* defaultValue */); 405 String zoneIdString = consumeText(parser); 406 407 // Make sure we are on the </id> element. 408 checkOnEndTag(parser, ZONE_ID_ELEMENT); 409 410 // Process the TimeZoneMapping. 411 if (zoneIdString == null || zoneIdString.length() == 0) { 412 throw new XmlPullParserException("Missing text for " + ZONE_ID_ELEMENT + "): " 413 + parser.getPositionDescription()); 414 } 415 416 TimeZoneMapping timeZoneMapping = 417 new TimeZoneMapping(zoneIdString, showInPicker, notUsedAfter); 418 timeZoneMappings.add(timeZoneMapping); 419 } 420 421 // The list is made unmodifiable to avoid callers changing it. 422 return Collections.unmodifiableList(timeZoneMappings); 423 } 424 425 /** 426 * Parses an attribute value, which must be either {@code null} or a valid signed long value. 427 * If the attribute value is {@code null} then {@code defaultValue} is returned. If the 428 * attribute is present but not a valid long value then an XmlPullParserException is thrown. 429 */ 430 private static Long parseLongAttribute(XmlPullParser parser, String attributeName, 431 Long defaultValue) throws XmlPullParserException { 432 String attributeValueString = parser.getAttributeValue(null /* namespace */, attributeName); 433 if (attributeValueString == null) { 434 return defaultValue; 435 } 436 try { 437 return Long.parseLong(attributeValueString); 438 } catch (NumberFormatException e) { 439 throw new XmlPullParserException("Attribute \"" + attributeName 440 + "\" is not a long value: " + parser.getPositionDescription()); 441 } 442 } 443 444 /** 445 * Parses an attribute value, which must be either {@code null}, {@code "y"} or {@code "n"}. 446 * If the attribute value is {@code null} then {@code defaultValue} is returned. If the 447 * attribute is present but not "y" or "n" then an XmlPullParserException is thrown. 448 */ 449 private static Boolean parseBooleanAttribute(XmlPullParser parser, 450 String attributeName, Boolean defaultValue) throws XmlPullParserException { 451 String attributeValueString = parser.getAttributeValue(null /* namespace */, attributeName); 452 if (attributeValueString == null) { 453 return defaultValue; 454 } 455 boolean isTrue = TRUE_ATTRIBUTE_VALUE.equals(attributeValueString); 456 if (!(isTrue || FALSE_ATTRIBUTE_VALUE.equals(attributeValueString))) { 457 throw new XmlPullParserException("Attribute \"" + attributeName 458 + "\" is not \"y\" or \"n\": " + parser.getPositionDescription()); 459 } 460 return isTrue; 461 } 462 463 private static void findRequiredStartTag(XmlPullParser parser, String elementName) 464 throws IOException, XmlPullParserException { 465 findStartTag(parser, elementName, true /* elementRequired */); 466 } 467 468 /** Called when on a START_TAG. When returning false, it leaves the parser on the END_TAG. */ 469 private static boolean findOptionalStartTag(XmlPullParser parser, String elementName) 470 throws IOException, XmlPullParserException { 471 return findStartTag(parser, elementName, false /* elementRequired */); 472 } 473 474 /** 475 * Find a START_TAG with the specified name without decreasing the depth, or increasing the 476 * depth by more than one. More deeply nested elements and text are skipped, even START_TAGs 477 * with matching names. Returns when the START_TAG is found or the next (non-nested) END_TAG is 478 * encountered. The return can take the form of an exception or a false if the START_TAG is not 479 * found. True is returned when it is. 480 */ 481 private static boolean findStartTag( 482 XmlPullParser parser, String elementName, boolean elementRequired) 483 throws IOException, XmlPullParserException { 484 485 int type; 486 while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) { 487 switch (type) { 488 case XmlPullParser.START_TAG: 489 String currentElementName = parser.getName(); 490 if (elementName.equals(currentElementName)) { 491 return true; 492 } 493 494 // It was not the START_TAG we were looking for. Consume until the end. 495 parser.next(); 496 consumeUntilEndTag(parser, currentElementName); 497 break; 498 case XmlPullParser.END_TAG: 499 if (elementRequired) { 500 throw new XmlPullParserException( 501 "No child element found with name " + elementName); 502 } 503 return false; 504 default: 505 // Ignore. 506 break; 507 } 508 } 509 throw new XmlPullParserException("Unexpected end of document while looking for " 510 + elementName); 511 } 512 513 /** 514 * Consume the remaining contents of an element and move to the END_TAG. Used when processing 515 * within an element can stop. The parser must be pointing at either the END_TAG we are looking 516 * for, a TEXT, or a START_TAG nested within the element to be consumed. 517 */ 518 private static void consumeUntilEndTag(XmlPullParser parser, String elementName) 519 throws IOException, XmlPullParserException { 520 521 if (parser.getEventType() == XmlPullParser.END_TAG 522 && elementName.equals(parser.getName())) { 523 // Early return - we are already there. 524 return; 525 } 526 527 // Keep track of the required depth in case there are nested elements to be consumed. 528 // Both the name and the depth must match our expectation to complete. 529 530 int requiredDepth = parser.getDepth(); 531 // A TEXT tag would be at the same depth as the END_TAG we are looking for. 532 if (parser.getEventType() == XmlPullParser.START_TAG) { 533 // A START_TAG would have incremented the depth, so we're looking for an END_TAG one 534 // higher than the current tag. 535 requiredDepth--; 536 } 537 538 while (parser.getEventType() != XmlPullParser.END_DOCUMENT) { 539 int type = parser.next(); 540 541 int currentDepth = parser.getDepth(); 542 if (currentDepth < requiredDepth) { 543 throw new XmlPullParserException( 544 "Unexpected depth while looking for end tag: " 545 + parser.getPositionDescription()); 546 } else if (currentDepth == requiredDepth) { 547 if (type == XmlPullParser.END_TAG) { 548 if (elementName.equals(parser.getName())) { 549 return; 550 } 551 throw new XmlPullParserException( 552 "Unexpected eng tag: " + parser.getPositionDescription()); 553 } 554 } 555 // Everything else is either a type we are not interested in or is too deep and so is 556 // ignored. 557 } 558 throw new XmlPullParserException("Unexpected end of document"); 559 } 560 561 /** 562 * Reads the text inside the current element. Should be called when the parser is currently 563 * on the START_TAG before the TEXT. The parser will be positioned on the END_TAG after this 564 * call when it completes successfully. 565 */ 566 private static String consumeText(XmlPullParser parser) 567 throws IOException, XmlPullParserException { 568 569 int type = parser.next(); 570 String text; 571 if (type == XmlPullParser.TEXT) { 572 text = parser.getText(); 573 } else { 574 throw new XmlPullParserException("Text not found. Found type=" + type 575 + " at " + parser.getPositionDescription()); 576 } 577 578 type = parser.next(); 579 if (type != XmlPullParser.END_TAG) { 580 throw new XmlPullParserException( 581 "Unexpected nested tag or end of document when expecting text: type=" + type 582 + " at " + parser.getPositionDescription()); 583 } 584 return text; 585 } 586 587 private static void checkOnEndTag(XmlPullParser parser, String elementName) 588 throws XmlPullParserException { 589 if (!(parser.getEventType() == XmlPullParser.END_TAG 590 && parser.getName().equals(elementName))) { 591 throw new XmlPullParserException( 592 "Unexpected tag encountered: " + parser.getPositionDescription()); 593 } 594 } 595 596 /** 597 * Processes <timezones> data. 598 */ 599 private interface TimeZonesProcessor { 600 601 boolean CONTINUE = true; 602 boolean HALT = false; 603 604 /** 605 * Return {@link #CONTINUE} if processing of the XML should continue, {@link #HALT} if it 606 * should stop (but without considering this an error). Problems with the data are 607 * reported as an exception. 608 * 609 * <p>The default implementation returns {@link #CONTINUE}. 610 */ 611 default boolean processHeader(String ianaVersion) throws XmlPullParserException { 612 return CONTINUE; 613 } 614 615 /** 616 * Returns {@link #CONTINUE} if processing of the XML should continue, {@link #HALT} if it 617 * should stop (but without considering this an error). Problems with the data are 618 * reported as an exception. 619 * 620 * <p>The default implementation returns {@link #CONTINUE}. 621 */ 622 default boolean processCountryZones(String countryIso, String defaultTimeZoneId, 623 boolean everUsesUtc, List<TimeZoneMapping> timeZoneMappings, String debugInfo) 624 throws XmlPullParserException { 625 return CONTINUE; 626 } 627 } 628 629 /** 630 * Validates <countryzones> elements. Intended to be used before a proposed installation 631 * of new data. To be valid the country ISO code must be normalized, unique, the default time 632 * zone ID must be one of the time zones IDs and the time zone IDs list must not be empty. The 633 * IDs themselves are not checked against other data to see if they are recognized because other 634 * classes will not have been updated with the associated new time zone data yet and so will not 635 * be aware of newly added IDs. 636 */ 637 private static class TimeZonesValidator implements TimeZonesProcessor { 638 639 private final Set<String> knownCountryCodes = new HashSet<>(); 640 641 @Override 642 public boolean processCountryZones(String countryIso, String defaultTimeZoneId, 643 boolean everUsesUtc, List<TimeZoneMapping> timeZoneMappings, String debugInfo) 644 throws XmlPullParserException { 645 if (!normalizeCountryIso(countryIso).equals(countryIso)) { 646 throw new XmlPullParserException("Country code: " + countryIso 647 + " is not normalized at " + debugInfo); 648 } 649 if (knownCountryCodes.contains(countryIso)) { 650 throw new XmlPullParserException("Second entry for country code: " + countryIso 651 + " at " + debugInfo); 652 } 653 if (timeZoneMappings.isEmpty()) { 654 throw new XmlPullParserException("No time zone IDs for country code: " + countryIso 655 + " at " + debugInfo); 656 } 657 if (!TimeZoneMapping.containsTimeZoneId(timeZoneMappings, defaultTimeZoneId)) { 658 throw new XmlPullParserException("defaultTimeZoneId for country code: " 659 + countryIso + " is not one of the zones " + timeZoneMappings + " at " 660 + debugInfo); 661 } 662 knownCountryCodes.add(countryIso); 663 664 return CONTINUE; 665 } 666 } 667 668 /** 669 * Reads just the IANA version from the file header. The version is then available via 670 * {@link #getIanaVersion()}. 671 */ 672 private static class IanaVersionExtractor implements TimeZonesProcessor { 673 674 private String ianaVersion; 675 676 @Override 677 public boolean processHeader(String ianaVersion) throws XmlPullParserException { 678 this.ianaVersion = ianaVersion; 679 return HALT; 680 } 681 682 public String getIanaVersion() { 683 return ianaVersion; 684 } 685 } 686 687 /** 688 * Reads all country time zone information into memory and makes it available as a 689 * {@link CountryZonesFinder}. 690 */ 691 private static class CountryZonesLookupExtractor implements TimeZonesProcessor { 692 private List<CountryTimeZones> countryTimeZonesList = new ArrayList<>(250 /* default */); 693 694 @Override 695 public boolean processCountryZones(String countryIso, String defaultTimeZoneId, 696 boolean everUsesUtc, List<TimeZoneMapping> timeZoneMappings, String debugInfo) 697 throws XmlPullParserException { 698 699 CountryTimeZones countryTimeZones = CountryTimeZones.createValidated( 700 countryIso, defaultTimeZoneId, everUsesUtc, timeZoneMappings, debugInfo); 701 countryTimeZonesList.add(countryTimeZones); 702 return CONTINUE; 703 } 704 705 CountryZonesFinder getCountryZonesLookup() { 706 return new CountryZonesFinder(countryTimeZonesList); 707 } 708 } 709 710 /** 711 * Extracts <em>validated</em> time zones information associated with a specific country code. 712 * Processing is halted when the country code is matched and the validated result is also made 713 * available via {@link #getValidatedCountryTimeZones()}. 714 */ 715 private static class SelectiveCountryTimeZonesExtractor implements TimeZonesProcessor { 716 717 private final String countryCodeToMatch; 718 private CountryTimeZones validatedCountryTimeZones; 719 720 private SelectiveCountryTimeZonesExtractor(String countryCodeToMatch) { 721 this.countryCodeToMatch = normalizeCountryIso(countryCodeToMatch); 722 } 723 724 @Override 725 public boolean processCountryZones(String countryIso, String defaultTimeZoneId, 726 boolean everUsesUtc, List<TimeZoneMapping> timeZoneMappings, String debugInfo) { 727 countryIso = normalizeCountryIso(countryIso); 728 if (!countryCodeToMatch.equals(countryIso)) { 729 return CONTINUE; 730 } 731 validatedCountryTimeZones = CountryTimeZones.createValidated(countryIso, 732 defaultTimeZoneId, everUsesUtc, timeZoneMappings, debugInfo); 733 734 return HALT; 735 } 736 737 /** 738 * Returns the CountryTimeZones that matched, or {@code null} if there were no matches. 739 */ 740 CountryTimeZones getValidatedCountryTimeZones() { 741 return validatedCountryTimeZones; 742 } 743 } 744 745 /** 746 * A source of Readers that can be used repeatedly. 747 */ 748 private interface ReaderSupplier { 749 /** Returns a Reader. Throws an IOException if the Reader cannot be created. */ 750 Reader get() throws IOException; 751 752 static ReaderSupplier forFile(String fileName, Charset charSet) throws IOException { 753 Path file = Paths.get(fileName); 754 if (!Files.exists(file)) { 755 throw new FileNotFoundException(fileName + " does not exist"); 756 } 757 if (!Files.isRegularFile(file) && Files.isReadable(file)) { 758 throw new IOException(fileName + " must be a regular readable file."); 759 } 760 return () -> Files.newBufferedReader(file, charSet); 761 } 762 763 static ReaderSupplier forString(String xml) { 764 return () -> new StringReader(xml); 765 } 766 } 767 768 private static List<String> extractTimeZoneIds(List<TimeZoneMapping> timeZoneMappings) { 769 List<String> zoneIds = new ArrayList<>(timeZoneMappings.size()); 770 for (TimeZoneMapping timeZoneMapping : timeZoneMappings) { 771 zoneIds.add(timeZoneMapping.timeZoneId); 772 } 773 return Collections.unmodifiableList(zoneIds); 774 } 775 776 static String normalizeCountryIso(String countryIso) { 777 // Lowercase ASCII is normalized for the purposes of the input files and the code in this 778 // class and related classes. 779 return countryIso.toLowerCase(Locale.US); 780 } 781 } 782