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
17package libcore.util;
18
19import org.junit.After;
20import org.junit.Before;
21import org.junit.Test;
22
23import android.icu.util.TimeZone;
24
25import java.io.IOException;
26import java.nio.charset.StandardCharsets;
27import java.nio.file.FileVisitResult;
28import java.nio.file.Files;
29import java.nio.file.Path;
30import java.nio.file.SimpleFileVisitor;
31import java.nio.file.attribute.BasicFileAttributes;
32import java.util.Arrays;
33import java.util.HashMap;
34import java.util.HashSet;
35import java.util.List;
36import java.util.Map;
37import java.util.Set;
38import java.util.stream.Collectors;
39
40import static org.junit.Assert.assertEquals;
41import static org.junit.Assert.assertNull;
42import static org.junit.Assert.fail;
43
44public class TimeZoneFinderTest {
45
46    private static final int HOUR_MILLIS = 60 * 60 * 1000;
47
48    // Zones used in the tests. NEW_YORK_TZ and LONDON_TZ chosen because they never overlap but both
49    // have DST.
50    private static final TimeZone NEW_YORK_TZ = TimeZone.getTimeZone("America/New_York");
51    private static final TimeZone LONDON_TZ = TimeZone.getTimeZone("Europe/London");
52    // A zone that matches LONDON_TZ for WHEN_NO_DST. It does not have DST so differs for WHEN_DST.
53    private static final TimeZone REYKJAVIK_TZ = TimeZone.getTimeZone("Atlantic/Reykjavik");
54    // Another zone that matches LONDON_TZ for WHEN_NO_DST. It does not have DST so differs for
55    // WHEN_DST.
56    private static final TimeZone UTC_TZ = TimeZone.getTimeZone("Etc/UTC");
57
58    // 22nd July 2017, 13:14:15 UTC (DST time in all the timezones used in these tests that observe
59    // DST).
60    private static final long WHEN_DST = 1500729255000L;
61    // 22nd January 2018, 13:14:15 UTC (non-DST time in all timezones used in these tests).
62    private static final long WHEN_NO_DST = 1516626855000L;
63
64    private static final int LONDON_DST_OFFSET_MILLIS = HOUR_MILLIS;
65    private static final int LONDON_NO_DST_OFFSET_MILLIS = 0;
66
67    private static final int NEW_YORK_DST_OFFSET_MILLIS = -4 * HOUR_MILLIS;
68    private static final int NEW_YORK_NO_DST_OFFSET_MILLIS = -5 * HOUR_MILLIS;
69
70    private Path testDir;
71
72    @Before
73    public void setUp() throws Exception {
74        testDir = Files.createTempDirectory("TimeZoneFinderTest");
75    }
76
77    @After
78    public void tearDown() throws Exception {
79        // Delete the testDir and all contents.
80        Files.walkFileTree(testDir, new SimpleFileVisitor<Path>() {
81            @Override
82            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
83                    throws IOException {
84                Files.delete(file);
85                return FileVisitResult.CONTINUE;
86            }
87
88            @Override
89            public FileVisitResult postVisitDirectory(Path dir, IOException exc)
90                    throws IOException {
91                Files.delete(dir);
92                return FileVisitResult.CONTINUE;
93            }
94        });
95    }
96
97    @Test
98    public void createInstanceWithFallback() throws Exception {
99        String validXml1 = "<timezones>\n"
100                + "  <countryzones>\n"
101                + "    <country code=\"gb\">\n"
102                + "      <id>Europe/London</id>\n"
103                + "    </country>\n"
104                + "  </countryzones>\n"
105                + "</timezones>\n";
106        String validXml2 = "<timezones>\n"
107                + "  <countryzones>\n"
108                + "    <country code=\"gb\">\n"
109                + "      <id>Europe/Paris</id>\n"
110                + "    </country>\n"
111                + "  </countryzones>\n"
112                + "</timezones>\n";
113
114        String invalidXml = "<foo></foo>\n";
115        checkValidateThrowsParserException(invalidXml);
116
117        String validFile1 = createFile(validXml1);
118        String validFile2 = createFile(validXml2);
119        String invalidFile = createFile(invalidXml);
120        String missingFile = createMissingFile();
121
122        TimeZoneFinder file1ThenFile2 =
123                TimeZoneFinder.createInstanceWithFallback(validFile1, validFile2);
124        assertZonesEqual(zones("Europe/London"), file1ThenFile2.lookupTimeZonesByCountry("gb"));
125
126        TimeZoneFinder missingFileThenFile1 =
127                TimeZoneFinder.createInstanceWithFallback(missingFile, validFile1);
128        assertZonesEqual(zones("Europe/London"),
129                missingFileThenFile1.lookupTimeZonesByCountry("gb"));
130
131        TimeZoneFinder file2ThenFile1 =
132                TimeZoneFinder.createInstanceWithFallback(validFile2, validFile1);
133        assertZonesEqual(zones("Europe/Paris"), file2ThenFile1.lookupTimeZonesByCountry("gb"));
134
135        // We assume the file has been validated so an invalid file is not checked ahead of time.
136        // We will find out when we look something up.
137        TimeZoneFinder invalidThenValid =
138                TimeZoneFinder.createInstanceWithFallback(invalidFile, validFile1);
139        assertNull(invalidThenValid.lookupTimeZonesByCountry("gb"));
140
141        // This is not a normal case: It would imply a define shipped without a file in /system!
142        TimeZoneFinder missingFiles =
143                TimeZoneFinder.createInstanceWithFallback(missingFile, missingFile);
144        assertNull(missingFiles.lookupTimeZonesByCountry("gb"));
145    }
146
147    @Test
148    public void xmlParsing_emptyFile() throws Exception {
149        checkValidateThrowsParserException("");
150    }
151
152    @Test
153    public void xmlParsing_unexpectedRootElement() throws Exception {
154        checkValidateThrowsParserException("<foo></foo>\n");
155    }
156
157    @Test
158    public void xmlParsing_missingCountryZones() throws Exception {
159        checkValidateThrowsParserException("<timezones></timezones>\n");
160    }
161
162    @Test
163    public void xmlParsing_noCountriesOk() throws Exception {
164        validate("<timezones>\n"
165                + "  <countryzones>\n"
166                + "  </countryzones>\n"
167                + "</timezones>\n");
168    }
169
170    @Test
171    public void xmlParsing_unexpectedComments() throws Exception {
172        TimeZoneFinder finder = validate("<timezones>\n"
173                + "  <countryzones>\n"
174                + "    <country code=\"gb\">\n"
175                + "      <!-- This is a comment -->"
176                + "      <id>Europe/London</id>\n"
177                + "    </country>\n"
178                + "  </countryzones>\n"
179                + "</timezones>\n");
180        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
181
182        // This is a crazy comment, but also helps prove that TEXT nodes are coalesced by the
183        // parser.
184        finder = validate("<timezones>\n"
185                + "  <countryzones>\n"
186                + "    <country code=\"gb\">\n"
187                + "      <id>Europe/<!-- Don't freak out! -->London</id>\n"
188                + "    </country>\n"
189                + "  </countryzones>\n"
190                + "</timezones>\n");
191        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
192    }
193
194    @Test
195    public void xmlParsing_unexpectedElementsIgnored() throws Exception {
196        String unexpectedElement = "<unexpected-element>\n<a /></unexpected-element>\n";
197        TimeZoneFinder finder = validate("<timezones>\n"
198                + "  " + unexpectedElement
199                + "  <countryzones>\n"
200                + "    <country code=\"gb\">\n"
201                + "      <id>Europe/London</id>\n"
202                + "    </country>\n"
203                + "  </countryzones>\n"
204                + "</timezones>\n");
205        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
206
207        finder = validate("<timezones>\n"
208                + "  <countryzones>\n"
209                + "    " + unexpectedElement
210                + "    <country code=\"gb\">\n"
211                + "      <id>Europe/London</id>\n"
212                + "    </country>\n"
213                + "  </countryzones>\n"
214                + "</timezones>\n");
215        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
216
217        finder = validate("<timezones>\n"
218                + "  <countryzones>\n"
219                + "    <country code=\"gb\">\n"
220                + "      " + unexpectedElement
221                + "      <id>Europe/London</id>\n"
222                + "    </country>\n"
223                + "  </countryzones>\n"
224                + "</timezones>\n");
225        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
226
227        finder = validate("<timezones>\n"
228                + "  <countryzones>\n"
229                + "    <country code=\"gb\">\n"
230                + "      <id>Europe/London</id>\n"
231                + "      " + unexpectedElement
232                + "      <id>Europe/Paris</id>\n"
233                + "    </country>\n"
234                + "  </countryzones>\n"
235                + "</timezones>\n");
236        assertZonesEqual(zones("Europe/London", "Europe/Paris"),
237                finder.lookupTimeZonesByCountry("gb"));
238
239        finder = validate("<timezones>\n"
240                + "  <countryzones>\n"
241                + "    <country code=\"gb\">\n"
242                + "      <id>Europe/London</id>\n"
243                + "    </country>\n"
244                + "    " + unexpectedElement
245                + "  </countryzones>\n"
246                + "</timezones>\n");
247        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
248
249        // This test is important because it ensures we can extend the format in future with
250        // more information.
251        finder = validate("<timezones>\n"
252                + "  <countryzones>\n"
253                + "    <country code=\"gb\">\n"
254                + "      <id>Europe/London</id>\n"
255                + "    </country>\n"
256                + "  </countryzones>\n"
257                + "  " + unexpectedElement
258                + "</timezones>\n");
259        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
260    }
261
262    @Test
263    public void xmlParsing_unexpectedTextIgnored() throws Exception {
264        String unexpectedText = "unexpected-text";
265        TimeZoneFinder finder = validate("<timezones>\n"
266                + "  " + unexpectedText
267                + "  <countryzones>\n"
268                + "    <country code=\"gb\">\n"
269                + "      <id>Europe/London</id>\n"
270                + "    </country>\n"
271                + "  </countryzones>\n"
272                + "</timezones>\n");
273        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
274
275        finder = validate("<timezones>\n"
276                + "  <countryzones>\n"
277                + "    " + unexpectedText
278                + "    <country code=\"gb\">\n"
279                + "      <id>Europe/London</id>\n"
280                + "    </country>\n"
281                + "  </countryzones>\n"
282                + "</timezones>\n");
283        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
284
285        finder = validate("<timezones>\n"
286                + "  <countryzones>\n"
287                + "    <country code=\"gb\">\n"
288                + "      " + unexpectedText
289                + "      <id>Europe/London</id>\n"
290                + "    </country>\n"
291                + "  </countryzones>\n"
292                + "</timezones>\n");
293        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
294
295        finder = validate("<timezones>\n"
296                + "  <countryzones>\n"
297                + "    <country code=\"gb\">\n"
298                + "      <id>Europe/London</id>\n"
299                + "      " + unexpectedText
300                + "      <id>Europe/Paris</id>\n"
301                + "    </country>\n"
302                + "  </countryzones>\n"
303                + "</timezones>\n");
304        assertZonesEqual(zones("Europe/London", "Europe/Paris"),
305                finder.lookupTimeZonesByCountry("gb"));
306    }
307
308    @Test
309    public void xmlParsing_truncatedInput() throws Exception {
310        checkValidateThrowsParserException("<timezones>\n");
311
312        checkValidateThrowsParserException("<timezones>\n"
313                + "  <countryzones>\n");
314
315        checkValidateThrowsParserException("<timezones>\n"
316                + "  <countryzones>\n"
317                + "    <country code=\"gb\">\n");
318
319        checkValidateThrowsParserException("<timezones>\n"
320                + "  <countryzones>\n"
321                + "    <country code=\"gb\">\n"
322                + "      <id>Europe/London</id>\n");
323
324        checkValidateThrowsParserException("<timezones>\n"
325                + "  <countryzones>\n"
326                + "    <country code=\"gb\">\n"
327                + "      <id>Europe/London</id>\n"
328                + "    </country>\n");
329
330        checkValidateThrowsParserException("<timezones>\n"
331                + "  <countryzones>\n"
332                + "    <country code=\"gb\">\n"
333                + "      <id>Europe/London</id>\n"
334                + "    </country>\n"
335                + "  </countryzones>\n");
336    }
337
338    @Test
339    public void xmlParsing_unexpectedChildInTimeZoneIdThrows() throws Exception {
340        checkValidateThrowsParserException("<timezones>\n"
341                + "  <countryzones>\n"
342                + "    <country code=\"gb\">\n"
343                + "      <id><unexpected-element /></id>\n"
344                + "    </country>\n"
345                + "  </countryzones>\n"
346                + "</timezones>\n");
347    }
348
349    @Test
350    public void xmlParsing_unknownTimeZoneIdIgnored() throws Exception {
351        TimeZoneFinder finder = validate("<timezones>\n"
352                + "  <countryzones>\n"
353                + "    <country code=\"gb\">\n"
354                + "      <id>Unknown_Id</id>\n"
355                + "      <id>Europe/London</id>\n"
356                + "    </country>\n"
357                + "  </countryzones>\n"
358                + "</timezones>\n");
359        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
360    }
361
362    @Test
363    public void xmlParsing_missingCountryCode() throws Exception {
364        checkValidateThrowsParserException("<timezones>\n"
365                + "  <countryzones>\n"
366                + "    <country>\n"
367                + "      <id>Europe/London</id>\n"
368                + "    </country>\n"
369                + "  </countryzones>\n"
370                + "</timezones>\n");
371    }
372
373    @Test
374    public void xmlParsing_unknownCountryReturnsNull() throws Exception {
375        TimeZoneFinder finder = validate("<timezones>\n"
376                + "  <countryzones>\n"
377                + "  </countryzones>\n"
378                + "</timezones>\n");
379        assertNull(finder.lookupTimeZonesByCountry("gb"));
380    }
381
382    @Test
383    public void lookupTimeZonesByCountry_structuresAreImmutable() throws Exception {
384        TimeZoneFinder finder = validate("<timezones>\n"
385                + "  <countryzones>\n"
386                + "    <country code=\"gb\">\n"
387                + "      <id>Europe/London</id>\n"
388                + "    </country>\n"
389                + "  </countryzones>\n"
390                + "</timezones>\n");
391
392        List<TimeZone> gbList = finder.lookupTimeZonesByCountry("gb");
393        assertEquals(1, gbList.size());
394        assertImmutableList(gbList);
395        assertImmutableTimeZone(gbList.get(0));
396
397        assertNull(finder.lookupTimeZonesByCountry("unknown"));
398    }
399
400    @Test
401    public void lookupTimeZoneByCountryAndOffset_unknownCountry() throws Exception {
402        TimeZoneFinder finder = validate("<timezones>\n"
403                + "  <countryzones>\n"
404                + "    <country code=\"xx\">\n"
405                + "      <id>Europe/London</id>\n"
406                + "    </country>\n"
407                + "  </countryzones>\n"
408                + "</timezones>\n");
409
410        // Demonstrate the arguments work for a known country.
411        assertZoneEquals(LONDON_TZ,
412                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS,
413                        true /* isDst */, WHEN_DST, null /* bias */));
414
415        // Test with an unknown country.
416        String unknownCountryCode = "yy";
417        assertNull(finder.lookupTimeZoneByCountryAndOffset(unknownCountryCode,
418                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, null /* bias */));
419
420        assertNull(finder.lookupTimeZoneByCountryAndOffset(unknownCountryCode,
421                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, LONDON_TZ /* bias */));
422    }
423
424    @Test
425    public void lookupTimeZoneByCountryAndOffset_oneCandidate() throws Exception {
426        TimeZoneFinder finder = validate("<timezones>\n"
427                + "  <countryzones>\n"
428                + "    <country code=\"xx\">\n"
429                + "      <id>Europe/London</id>\n"
430                + "    </country>\n"
431                + "  </countryzones>\n"
432                + "</timezones>\n");
433
434        // The three parameters match the configured zone: offset, isDst and when.
435        assertZoneEquals(LONDON_TZ,
436                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS,
437                        true /* isDst */, WHEN_DST, null /* bias */));
438        assertZoneEquals(LONDON_TZ,
439                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_NO_DST_OFFSET_MILLIS,
440                        false /* isDst */, WHEN_NO_DST, null /* bias */));
441
442        // Some lookup failure cases where the offset, isDst and when do not match the configured
443        // zone.
444        TimeZone noDstMatch1 = finder.lookupTimeZoneByCountryAndOffset("xx",
445                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_NO_DST, null /* bias */);
446        assertNull(noDstMatch1);
447
448        TimeZone noDstMatch2 = finder.lookupTimeZoneByCountryAndOffset("xx",
449                LONDON_DST_OFFSET_MILLIS, false /* isDst */, WHEN_NO_DST, null /* bias */);
450        assertNull(noDstMatch2);
451
452        TimeZone noDstMatch3 = finder.lookupTimeZoneByCountryAndOffset("xx",
453                LONDON_NO_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, null /* bias */);
454        assertNull(noDstMatch3);
455
456        TimeZone noDstMatch4 = finder.lookupTimeZoneByCountryAndOffset("xx",
457                LONDON_NO_DST_OFFSET_MILLIS, true /* isDst */, WHEN_NO_DST, null /* bias */);
458        assertNull(noDstMatch4);
459
460        TimeZone noDstMatch5 = finder.lookupTimeZoneByCountryAndOffset("xx",
461                LONDON_DST_OFFSET_MILLIS, false /* isDst */, WHEN_DST, null /* bias */);
462        assertNull(noDstMatch5);
463
464        TimeZone noDstMatch6 = finder.lookupTimeZoneByCountryAndOffset("xx",
465                LONDON_NO_DST_OFFSET_MILLIS, false /* isDst */, WHEN_DST, null /* bias */);
466        assertNull(noDstMatch6);
467
468        // Some bias cases below.
469
470        // The bias is irrelevant here: it matches what would be returned anyway.
471        assertZoneEquals(LONDON_TZ,
472                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS,
473                        true /* isDst */, WHEN_DST, LONDON_TZ /* bias */));
474        assertZoneEquals(LONDON_TZ,
475                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_NO_DST_OFFSET_MILLIS,
476                        false /* isDst */, WHEN_NO_DST, LONDON_TZ /* bias */));
477        // A sample of a non-matching case with bias.
478        assertNull(finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS,
479                true /* isDst */, WHEN_NO_DST, LONDON_TZ /* bias */));
480
481        // The bias should be ignored: it doesn't match any of the country's zones.
482        assertZoneEquals(LONDON_TZ,
483                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS,
484                        true /* isDst */, WHEN_DST, NEW_YORK_TZ /* bias */));
485
486        // The bias should still be ignored even though it matches the offset information given:
487        // it doesn't match any of the country's configured zones.
488        assertNull(finder.lookupTimeZoneByCountryAndOffset("xx", NEW_YORK_DST_OFFSET_MILLIS,
489                true /* isDst */, WHEN_DST, NEW_YORK_TZ /* bias */));
490    }
491
492    @Test
493    public void lookupTimeZoneByCountryAndOffset_multipleNonOverlappingCandidates()
494            throws Exception {
495        TimeZoneFinder finder = validate("<timezones>\n"
496                + "  <countryzones>\n"
497                + "    <country code=\"xx\">\n"
498                + "      <id>America/New_York</id>\n"
499                + "      <id>Europe/London</id>\n"
500                + "    </country>\n"
501                + "  </countryzones>\n"
502                + "</timezones>\n");
503
504        // The three parameters match the configured zone: offset, isDst and when.
505        assertZoneEquals(LONDON_TZ, finder.lookupTimeZoneByCountryAndOffset("xx",
506                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, null /* bias */));
507        assertZoneEquals(LONDON_TZ, finder.lookupTimeZoneByCountryAndOffset("xx",
508                LONDON_NO_DST_OFFSET_MILLIS, false /* isDst */, WHEN_NO_DST, null /* bias */));
509        assertZoneEquals(NEW_YORK_TZ, finder.lookupTimeZoneByCountryAndOffset("xx",
510                NEW_YORK_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, null /* bias */));
511        assertZoneEquals(NEW_YORK_TZ, finder.lookupTimeZoneByCountryAndOffset("xx",
512                NEW_YORK_NO_DST_OFFSET_MILLIS, false /* isDst */, WHEN_NO_DST, null /* bias */));
513
514        // Some lookup failure cases where the offset, isDst and when do not match the configured
515        // zone. This is a sample, not complete.
516        TimeZone noDstMatch1 = finder.lookupTimeZoneByCountryAndOffset("xx",
517                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_NO_DST, null /* bias */);
518        assertNull(noDstMatch1);
519
520        TimeZone noDstMatch2 = finder.lookupTimeZoneByCountryAndOffset("xx",
521                LONDON_DST_OFFSET_MILLIS, false /* isDst */, WHEN_NO_DST, null /* bias */);
522        assertNull(noDstMatch2);
523
524        TimeZone noDstMatch3 = finder.lookupTimeZoneByCountryAndOffset("xx",
525                NEW_YORK_NO_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, null /* bias */);
526        assertNull(noDstMatch3);
527
528        TimeZone noDstMatch4 = finder.lookupTimeZoneByCountryAndOffset("xx",
529                NEW_YORK_NO_DST_OFFSET_MILLIS, true /* isDst */, WHEN_NO_DST, null /* bias */);
530        assertNull(noDstMatch4);
531
532        TimeZone noDstMatch5 = finder.lookupTimeZoneByCountryAndOffset("xx",
533                LONDON_DST_OFFSET_MILLIS, false /* isDst */, WHEN_DST, null /* bias */);
534        assertNull(noDstMatch5);
535
536        TimeZone noDstMatch6 = finder.lookupTimeZoneByCountryAndOffset("xx",
537                LONDON_NO_DST_OFFSET_MILLIS, false /* isDst */, WHEN_DST, null /* bias */);
538        assertNull(noDstMatch6);
539
540        // Some bias cases below.
541
542        // The bias is irrelevant here: it matches what would be returned anyway.
543        assertZoneEquals(LONDON_TZ,
544                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS,
545                        true /* isDst */, WHEN_DST, LONDON_TZ /* bias */));
546        assertZoneEquals(LONDON_TZ,
547                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_NO_DST_OFFSET_MILLIS,
548                        false /* isDst */, WHEN_NO_DST, LONDON_TZ /* bias */));
549        // A sample of a non-matching case with bias.
550        assertNull(finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS,
551                true /* isDst */, WHEN_NO_DST, LONDON_TZ /* bias */));
552
553        // The bias should be ignored: it matches a configured zone, but the offset is wrong so
554        // should not be considered a match.
555        assertZoneEquals(LONDON_TZ,
556                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS,
557                        true /* isDst */, WHEN_DST, NEW_YORK_TZ /* bias */));
558    }
559
560    // This is an artificial case very similar to America/Denver and America/Phoenix in the US: both
561    // have the same offset for 6 months of the year but diverge. Australia/Lord_Howe too.
562    @Test
563    public void lookupTimeZoneByCountryAndOffset_multipleOverlappingCandidates() throws Exception {
564        // Three zones that have the same offset for some of the year. Europe/London changes
565        // offset WHEN_DST, the others do not.
566        TimeZoneFinder finder = validate("<timezones>\n"
567                + "  <countryzones>\n"
568                + "    <country code=\"xx\">\n"
569                + "      <id>Atlantic/Reykjavik</id>\n"
570                + "      <id>Europe/London</id>\n"
571                + "      <id>Etc/UTC</id>\n"
572                + "    </country>\n"
573                + "  </countryzones>\n"
574                + "</timezones>\n");
575
576        // This is the no-DST offset for LONDON_TZ, REYKJAVIK_TZ. UTC_TZ.
577        final int noDstOffset = LONDON_NO_DST_OFFSET_MILLIS;
578        // This is the DST offset for LONDON_TZ.
579        final int dstOffset = LONDON_DST_OFFSET_MILLIS;
580
581        // The three parameters match the configured zone: offset, isDst and when.
582        assertZoneEquals(LONDON_TZ, finder.lookupTimeZoneByCountryAndOffset("xx", dstOffset,
583                true /* isDst */, WHEN_DST, null /* bias */));
584        assertZoneEquals(REYKJAVIK_TZ, finder.lookupTimeZoneByCountryAndOffset("xx", noDstOffset,
585                false /* isDst */, WHEN_NO_DST, null /* bias */));
586        assertZoneEquals(LONDON_TZ, finder.lookupTimeZoneByCountryAndOffset("xx", dstOffset,
587                true /* isDst */, WHEN_DST, null /* bias */));
588        assertZoneEquals(REYKJAVIK_TZ, finder.lookupTimeZoneByCountryAndOffset("xx", noDstOffset,
589                false /* isDst */, WHEN_NO_DST, null /* bias */));
590        assertZoneEquals(REYKJAVIK_TZ, finder.lookupTimeZoneByCountryAndOffset("xx", noDstOffset,
591                false /* isDst */, WHEN_DST, null /* bias */));
592
593        // Some lookup failure cases where the offset, isDst and when do not match the configured
594        // zones.
595        TimeZone noDstMatch1 = finder.lookupTimeZoneByCountryAndOffset("xx", dstOffset,
596                true /* isDst */, WHEN_NO_DST, null /* bias */);
597        assertNull(noDstMatch1);
598
599        TimeZone noDstMatch2 = finder.lookupTimeZoneByCountryAndOffset("xx", noDstOffset,
600                true /* isDst */, WHEN_DST, null /* bias */);
601        assertNull(noDstMatch2);
602
603        TimeZone noDstMatch3 = finder.lookupTimeZoneByCountryAndOffset("xx", noDstOffset,
604                true /* isDst */, WHEN_NO_DST, null /* bias */);
605        assertNull(noDstMatch3);
606
607        TimeZone noDstMatch4 = finder.lookupTimeZoneByCountryAndOffset("xx", dstOffset,
608                false /* isDst */, WHEN_DST, null /* bias */);
609        assertNull(noDstMatch4);
610
611
612        // Some bias cases below.
613
614        // The bias is relevant here: it overrides what would be returned naturally.
615        assertZoneEquals(REYKJAVIK_TZ, finder.lookupTimeZoneByCountryAndOffset("xx", noDstOffset,
616                false /* isDst */, WHEN_NO_DST, null /* bias */));
617        assertZoneEquals(LONDON_TZ, finder.lookupTimeZoneByCountryAndOffset("xx", noDstOffset,
618                false /* isDst */, WHEN_NO_DST, LONDON_TZ /* bias */));
619        assertZoneEquals(UTC_TZ, finder.lookupTimeZoneByCountryAndOffset("xx", noDstOffset,
620                false /* isDst */, WHEN_NO_DST, UTC_TZ /* bias */));
621
622        // The bias should be ignored: it matches a configured zone, but the offset is wrong so
623        // should not be considered a match.
624        assertZoneEquals(LONDON_TZ, finder.lookupTimeZoneByCountryAndOffset("xx",
625                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, REYKJAVIK_TZ /* bias */));
626    }
627
628    @Test
629    public void consistencyTest() throws Exception {
630        // Confirm that no new zones have been added to zones.tab without also adding them to the
631        // configuration used to drive TimeZoneFinder.
632
633        // zone.tab is a tab separated ASCII file provided by IANA and included in Android's tzdata
634        // file. Each line contains a mapping from country code -> zone ID. The ordering used by
635        // TimeZoneFinder is Android-specific, but we can use zone.tab to make sure we know about
636        // all country zones. Any update to tzdata that adds, renames, or removes zones should be
637        // reflected in the file used by TimeZoneFinder.
638        Map<String, Set<String>> zoneTabMappings = new HashMap<>();
639        for (String line : ZoneInfoDB.getInstance().getZoneTab().split("\n")) {
640            int countryCodeEnd = line.indexOf('\t', 1);
641            int olsonIdStart = line.indexOf('\t', 4) + 1;
642            int olsonIdEnd = line.indexOf('\t', olsonIdStart);
643            if (olsonIdEnd == -1) {
644                olsonIdEnd = line.length(); // Not all zone.tab lines have a comment.
645            }
646            String countryCode = line.substring(0, countryCodeEnd);
647            String olsonId = line.substring(olsonIdStart, olsonIdEnd);
648            Set<String> zoneIds = zoneTabMappings.get(countryCode);
649            if (zoneIds == null) {
650                zoneIds = new HashSet<>();
651                zoneTabMappings.put(countryCode, zoneIds);
652            }
653            zoneIds.add(olsonId);
654        }
655
656        TimeZoneFinder timeZoneFinder = TimeZoneFinder.getInstance();
657        for (Map.Entry<String, Set<String>> countryEntry : zoneTabMappings.entrySet()) {
658            String countryCode = countryEntry.getKey();
659            // Android uses lower case, IANA uses upper.
660            countryCode = countryCode.toLowerCase();
661
662            List<String> ianaZoneIds = countryEntry.getValue().stream().sorted()
663                    .collect(Collectors.toList());
664            List<TimeZone> androidZones = timeZoneFinder.lookupTimeZonesByCountry(countryCode);
665            List<String> androidZoneIds =
666                    androidZones.stream().map(TimeZone::getID).sorted()
667                            .collect(Collectors.toList());
668
669            assertEquals("Android zones for " + countryCode + " do not match IANA data",
670                    ianaZoneIds, androidZoneIds);
671        }
672    }
673
674    private void assertImmutableTimeZone(TimeZone timeZone) {
675        try {
676            timeZone.setRawOffset(1000);
677            fail();
678        } catch (UnsupportedOperationException expected) {
679        }
680    }
681
682    private static void assertImmutableList(List<TimeZone> timeZones) {
683        try {
684            timeZones.add(null);
685            fail();
686        } catch (UnsupportedOperationException expected) {
687        }
688    }
689
690    private static void assertZoneEquals(TimeZone expected, TimeZone actual) {
691        // TimeZone.equals() only checks the ID, but that's ok for these tests.
692        assertEquals(expected, actual);
693    }
694
695    private static void assertZonesEqual(List<TimeZone> expected, List<TimeZone> actual) {
696        // TimeZone.equals() only checks the ID, but that's ok for these tests.
697        assertEquals(expected, actual);
698    }
699
700    private static void checkValidateThrowsParserException(String xml) throws Exception {
701        try {
702            validate(xml);
703            fail();
704        } catch (IOException expected) {
705        }
706    }
707
708    private static TimeZoneFinder validate(String xml) throws IOException {
709        TimeZoneFinder timeZoneFinder = TimeZoneFinder.createInstanceForTests(xml);
710        timeZoneFinder.validate();
711        return timeZoneFinder;
712    }
713
714    private static List<TimeZone> zones(String... ids) {
715        return Arrays.stream(ids).map(TimeZone::getTimeZone).collect(Collectors.toList());
716    }
717
718    private String createFile(String fileContent) throws IOException {
719        Path filePath = Files.createTempFile(testDir, null, null);
720        Files.write(filePath, fileContent.getBytes(StandardCharsets.UTF_8));
721        return filePath.toString();
722    }
723
724    private String createMissingFile() throws IOException {
725        Path filePath = Files.createTempFile(testDir, null, null);
726        Files.delete(filePath);
727        return filePath.toString();
728    }
729}
730