1 /*
2  * Copyright (C) 2022 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 androidx.core.i18n
18 
19 import android.os.Build
20 import android.util.Log
21 import androidx.core.i18n.DateTimeFormatterSkeletonOptions as SkeletonOptions
22 import androidx.test.ext.junit.runners.AndroidJUnit4
23 import androidx.test.filters.LargeTest
24 import androidx.test.filters.SdkSuppress
25 import androidx.test.filters.SmallTest
26 import androidx.test.platform.app.InstrumentationRegistry
27 import java.util.Calendar
28 import java.util.Date
29 import java.util.GregorianCalendar
30 import java.util.Locale
31 import java.util.TimeZone
32 import kotlin.test.assertFailsWith
33 import org.junit.After
34 import org.junit.Assert.assertEquals
35 import org.junit.Before
36 import org.junit.BeforeClass
37 import org.junit.Test
38 import org.junit.runner.RunWith
39 
40 /** Must execute on an Android device. */
41 @RunWith(AndroidJUnit4::class)
42 class DateTimeFormatterTest {
43     companion object {
44         // Lollipop introduced Locale.toLanguageTag and Locale.forLanguageTag
45         private const val AVAILABLE_LANGUAGE_TAG = Build.VERSION_CODES.LOLLIPOP
46 
47         // From this version we can access ICU4J public APIs (android.icu.*)
48         private const val AVAILABLE_ICU4J = Build.VERSION_CODES.N
49         private const val AVAILABLE_HC_U_EXT = Build.VERSION_CODES.S
50         private const val AVAILABLE_PERIOD_B = Build.VERSION_CODES.Q
51 
52         private var sSavedTimeZone: TimeZone? = null
53 
54         @BeforeClass
beforeClassnull55         fun beforeClass() {
56             sSavedTimeZone = TimeZone.getDefault()
57         }
58     }
59 
60     /** Starting with Android N ICU4J is public API. */
61     private val isIcuAvailable = Build.VERSION.SDK_INT >= AVAILABLE_ICU4J
62     /** Starting with Android S ICU honors the "-u-hc-" extension in locale id. */
63     private val isHcExtensionHonored = Build.VERSION.SDK_INT >= AVAILABLE_HC_U_EXT
64     /** Starting with Android Q ICU supports "b" and "B". */
65     private val isFlexiblePeriodAvailable = Build.VERSION.SDK_INT >= AVAILABLE_PERIOD_B
66 
67     private val logTag = this::class.qualifiedName
68     private val appContext = InstrumentationRegistry.getInstrumentation().targetContext
69 
70     private val defaultTimeZone = TimeZone.getTimeZone("America/Los_Angeles")
71     private val testCalendar = GregorianCalendar(defaultTimeZone)
72 
73     init {
74         // Sept 19, 2021, 21:42:12.345
75         testCalendar.timeInMillis = 1632112932345L
76     }
77 
78     private val testDate = testCalendar.time
79     private val testMillis = testCalendar.timeInMillis
80 
81     @Before
beforeTestnull82     fun beforeTest() {
83         // Some of the test check that the functionality honors the default timezone.
84         // So we make sure it is set to something we control.
85         TimeZone.setDefault(defaultTimeZone)
86     }
87 
88     @After
afterTestnull89     fun afterTest() {
90         if (sSavedTimeZone != null) {
91             TimeZone.setDefault(sSavedTimeZone)
92         }
93     }
94 
95     @Test
96     @SmallTest
testnull97     fun test() {
98         val locale = Locale.US
99         val options = SkeletonOptions.fromString("yMMMdjms")
100         val expected =
101             when {
102                 Build.VERSION.SDK_INT >= 34 -> "Sep 19, 2021, 9:42:12\u202FPM"
103                 else -> "Sep 19, 2021, 9:42:12 PM"
104             }
105 
106         // Test Calendar
107         assertEquals(expected, DateTimeFormatter(appContext, options, locale).format(testCalendar))
108         // Test Date
109         assertEquals(expected, DateTimeFormatter(appContext, options, locale).format(testDate))
110         // Test milliseconds
111         assertEquals(expected, DateTimeFormatter(appContext, options, locale).format(testMillis))
112     }
113 
114     @Test
115     @SmallTest
testApinull116     fun testApi() {
117         val builder =
118             SkeletonOptions.Builder()
119                 .setYear(SkeletonOptions.Year.NUMERIC)
120                 .setMonth(SkeletonOptions.Month.ABBREVIATED)
121                 .setDay(SkeletonOptions.Day.NUMERIC)
122                 .setHour(SkeletonOptions.Hour.NUMERIC)
123                 .setMinute(SkeletonOptions.Minute.NUMERIC)
124                 .setSecond(SkeletonOptions.Second.NUMERIC)
125 
126         val localeFr = Locale.FRANCE
127         val localeUs = Locale.US
128 
129         val expectedUs12 =
130             when {
131                 Build.VERSION.SDK_INT >= 34 -> "Sep 19, 2021, 9:42:12\u202FPM"
132                 else -> "Sep 19, 2021, 9:42:12 PM"
133             }
134         val expectedUs24 = "Sep 19, 2021, 21:42:12"
135         val expectedUs12Milli =
136             when {
137                 Build.VERSION.SDK_INT >= 34 -> "Sep 19, 2021, 9:42:12.345\u202FPM"
138                 else -> "Sep 19, 2021, 9:42:12.345 PM"
139             }
140 
141         val expectedFr12: String
142         val expectedFr24: String
143         when {
144             Build.VERSION.SDK_INT >= 34 -> { // >= 31
145                 expectedFr12 = "19 sept. 2021, 9:42:12\u202FPM"
146                 expectedFr24 = "19 sept. 2021, 21:42:12"
147             }
148             Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { // >= 31
149                 expectedFr12 = "19 sept. 2021, 9:42:12 PM"
150                 expectedFr24 = "19 sept. 2021, 21:42:12"
151             }
152             Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> { // [24, 31)
153                 expectedFr12 = "19 sept. 2021 à 9:42:12 PM"
154                 expectedFr24 = "19 sept. 2021 à 21:42:12"
155             }
156             else -> { // < 24
157                 expectedFr12 = "19 sept. 2021 9:42:12 PM"
158                 expectedFr24 = "19 sept. 2021 21:42:12"
159             }
160         }
161 
162         var options = builder.build()
163 
164         var formatter = DateTimeFormatter(appContext, options, localeFr)
165         assertEquals(expectedFr24, formatter.format(testDate))
166         assertEquals(expectedFr24, formatter.format(testCalendar))
167 
168         options = builder.setHour(SkeletonOptions.Hour.NUMERIC).build()
169         formatter = DateTimeFormatter(appContext, options, localeFr)
170         assertEquals(expectedFr24, formatter.format(testDate)) // fr-FR default is h24
171 
172         options = builder.setHour(SkeletonOptions.Hour.FORCE_12H_NUMERIC).build()
173         formatter = DateTimeFormatter(appContext, options, localeFr)
174         assertEquals(expectedFr12, formatter.format(testDate)) // force to h12
175 
176         options = builder.setHour(SkeletonOptions.Hour.FORCE_24H_NUMERIC).build()
177         formatter = DateTimeFormatter(appContext, options, localeFr)
178         assertEquals(expectedFr24, formatter.format(testDate)) // force to h24
179 
180         options = builder.setHour(SkeletonOptions.Hour.NUMERIC).build()
181         formatter = DateTimeFormatter(appContext, options, localeUs)
182         assertEquals(expectedUs12, formatter.format(testDate)) // en-US default is h12
183 
184         options = builder.setHour(SkeletonOptions.Hour.FORCE_12H_NUMERIC).build()
185         formatter = DateTimeFormatter(appContext, options, localeUs)
186         assertEquals(expectedUs12, formatter.format(testDate)) // force to h12
187 
188         options = builder.setHour(SkeletonOptions.Hour.FORCE_24H_NUMERIC).build()
189         formatter = DateTimeFormatter(appContext, options, localeUs)
190         assertEquals(expectedUs24, formatter.format(testDate)) // force to h12
191 
192         // Make sure that the milliseconds are formatted
193         options =
194             builder
195                 .setHour(SkeletonOptions.Hour.NUMERIC)
196                 .setFractionalSecond(SkeletonOptions.FractionalSecond.NUMERIC_3_DIGITS)
197                 .build()
198         formatter = DateTimeFormatter(appContext, options, localeUs)
199         assertEquals(expectedUs12Milli, formatter.format(testDate))
200         assertEquals(expectedUs12Milli, formatter.format(testCalendar))
201     }
202 
203     @Test
204     @SmallTest
205     @SdkSuppress(minSdkVersion = AVAILABLE_LANGUAGE_TAG)
206     // Without `Locale.forLanguageTag` we can't even build a locale with `-u-` extension.
testSystemSupportForExtensionUnull207     fun testSystemSupportForExtensionU() {
208         val enUsForceH11 = Locale.forLanguageTag("en-US-u-hc-h11")
209         val enUsForceH12 = Locale.forLanguageTag("en-US-u-hc-h12")
210         val enUsForceH23 = Locale.forLanguageTag("en-US-u-hc-h23")
211         val enUsForceH24 = Locale.forLanguageTag("en-US-u-hc-h24")
212 
213         val expectedUs: String = "9:42:12 PM"
214         val expectedUs11: String = expectedUs
215         val expectedUs12: String = expectedUs
216         // The `-u-hc-` option is not honored for the predefined formats
217         // (`DateFormat.MEDIUM` and so on). Works for patterns generated from skeletons.
218         // Fixed in ICU 74.
219         // Official bug: https://unicode-org.atlassian.net/browse/ICU-11870
220         val expectedUs23 =
221             when {
222                 Build.VERSION.SDK_INT >= 35 -> "21:42:12"
223                 else -> expectedUs
224             }
225         val expectedUs24: String = expectedUs23
226 
227         var formatter: java.text.DateFormat
228 
229         // Formatting with style does not honor the uc overrides
230         formatter = java.text.DateFormat.getTimeInstance(java.text.DateFormat.MEDIUM, Locale.US)
231         assertEquals(expectedUs, Helper.normalizeNnbsp(formatter.format(testMillis)))
232         formatter = java.text.DateFormat.getTimeInstance(java.text.DateFormat.MEDIUM, enUsForceH11)
233         assertEquals(expectedUs11, Helper.normalizeNnbsp(formatter.format(testMillis)))
234         formatter = java.text.DateFormat.getTimeInstance(java.text.DateFormat.MEDIUM, enUsForceH12)
235         assertEquals(expectedUs12, Helper.normalizeNnbsp(formatter.format(testMillis)))
236         formatter = java.text.DateFormat.getTimeInstance(java.text.DateFormat.MEDIUM, enUsForceH23)
237         assertEquals(expectedUs23, Helper.normalizeNnbsp(formatter.format(testMillis)))
238         formatter = java.text.DateFormat.getTimeInstance(java.text.DateFormat.MEDIUM, enUsForceH24)
239         assertEquals(expectedUs24, Helper.normalizeNnbsp(formatter.format(testMillis)))
240     }
241 
242     @Test
243     @SmallTest
244     @SdkSuppress(minSdkVersion = AVAILABLE_LANGUAGE_TAG)
testHourCycleOverridesnull245     fun testHourCycleOverrides() {
246         val expectedUs12 =
247             when {
248                 Build.VERSION.SDK_INT >= 34 -> "Sep 19, 2021, 9:42:12\u202FPM"
249                 else -> "Sep 19, 2021, 9:42:12 PM"
250             }
251         val expectedUs24 = "Sep 19, 2021, 21:42:12"
252         val builder =
253             SkeletonOptions.Builder()
254                 .setYear(SkeletonOptions.Year.NUMERIC)
255                 .setMonth(SkeletonOptions.Month.ABBREVIATED)
256                 .setDay(SkeletonOptions.Day.NUMERIC)
257                 .setHour(SkeletonOptions.Hour.NUMERIC)
258                 .setMinute(SkeletonOptions.Minute.NUMERIC)
259                 .setSecond(SkeletonOptions.Second.NUMERIC)
260         val locale = Locale.forLanguageTag("en-US-u-hc-h23")
261 
262         var formatter: DateTimeFormatter
263         if (isHcExtensionHonored) {
264             formatter =
265                 DateTimeFormatter(
266                     appContext,
267                     builder.setHour(SkeletonOptions.Hour.NUMERIC).build(),
268                     locale
269                 )
270             // en-US default is h12, but hc forces it to 24
271             assertEquals(expectedUs24, formatter.format(testDate))
272         } else {
273             formatter =
274                 DateTimeFormatter(
275                     appContext,
276                     builder.setHour(SkeletonOptions.Hour.NUMERIC).build(),
277                     locale
278                 )
279             assertEquals(expectedUs12, formatter.format(testDate)) // hc is ignored
280         }
281 
282         formatter =
283             DateTimeFormatter(
284                 appContext,
285                 builder.setHour(SkeletonOptions.Hour.FORCE_12H_NUMERIC).build(),
286                 locale
287             )
288         assertEquals(expectedUs12, formatter.format(testDate)) // force to h12
289 
290         formatter =
291             DateTimeFormatter(
292                 appContext,
293                 builder.setHour(SkeletonOptions.Hour.FORCE_24H_NUMERIC).build(),
294                 locale
295             )
296         assertEquals(expectedUs24, formatter.format(testDate)) // force to h12
297     }
298 
299     @Test
300     @LargeTest
testApi26And27PatternHasBnull301     fun testApi26And27PatternHasB() {
302         val options = SkeletonOptions.fromString("yMMMdjmsSSS")
303         val now = Date()
304 
305         // TODO: move to a different test class and try to abuse
306         //  all skeleton combinations on all API levels
307         for (locale in Locale.getAvailableLocales()) {
308             try {
309                 DateTimeFormatter(appContext, options, locale).format(now)
310             } catch (e: java.lang.IllegalArgumentException) {
311                 Log.e(logTag, "Failed for '" + locale + "': " + e.message)
312             }
313         }
314     }
315 
316     @Test
317     @SmallTest
testBbbnull318     fun testBbb() {
319         val builder =
320             SkeletonOptions.Builder()
321                 .setHour(SkeletonOptions.Hour.NUMERIC)
322                 .setPeriod(SkeletonOptions.Period.FLEXIBLE)
323                 .setMinute(SkeletonOptions.Minute.NUMERIC)
324 
325         val formatterUs = DateTimeFormatter(appContext, builder.build(), Locale.US)
326         val formatterZh = DateTimeFormatter(appContext, builder.build(), Locale.CHINA)
327         val formatterFr = DateTimeFormatter(appContext, builder.build(), Locale.FRANCE)
328 
329         val expectedUs =
330             if (isFlexiblePeriodAvailable) {
331                 "12:43 at night || 4:43 at night || 8:43 in the morning || " +
332                     "12:43 in the afternoon || 4:43 in the afternoon || 8:43 in the evening"
333             } else {
334                 "12:43 AM || 4:43 AM || 8:43 AM || 12:43 PM || 4:43 PM || 8:43 PM"
335             }
336         val expectedZh =
337             when {
338                 // Chinese changed to 24h from ICU 70.1
339                 Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ->
340                     "00:43 || 04:43 || 08:43 || 12:43 || 16:43 || 20:43"
341                 isFlexiblePeriodAvailable ->
342                     "凌晨12:43 || 凌晨4:43 || 上午8:43 || 中午12:43 || 下午4:43 || 晚上8:43"
343                 else -> "上午12:43 || 上午4:43 || 上午8:43 || 下午12:43 || 下午4:43 || 下午8:43"
344             }
345         val expectedFr = "00:43 || 04:43 || 08:43 || 12:43 || 16:43 || 20:43"
346 
347         val calendar = Calendar.getInstance()
348         val separator = " || "
349 
350         // StringJoiner would be nicer, but it is only available from Android 24
351         val resultUs = StringBuilder()
352         val resultZh = StringBuilder()
353         val resultFr = StringBuilder()
354 
355         for (hour in 0..23 step 4) {
356             calendar.set(2022, Calendar.JANUARY, 15, hour, 43)
357             if (hour != 0) {
358                 resultUs.append(separator)
359                 resultZh.append(separator)
360                 resultFr.append(separator)
361             }
362             resultUs.append(formatterUs.format(calendar))
363             resultZh.append(formatterZh.format(calendar))
364             resultFr.append(formatterFr.format(calendar))
365         }
366 
367         assertEquals(expectedUs, resultUs.toString())
368         assertEquals(expectedZh, resultZh.toString())
369         assertEquals(expectedFr, resultFr.toString())
370     }
371 
372     @Test
373     @SmallTest
testEranull374     fun testEra() {
375         val builder =
376             SkeletonOptions.Builder()
377                 .setYear(SkeletonOptions.Year.NUMERIC)
378                 .setMonth(SkeletonOptions.Month.ABBREVIATED)
379                 .setEra(SkeletonOptions.Era.ABBREVIATED)
380 
381         val dateBc = Calendar.getInstance()
382         dateBc.set(-42, Calendar.SEPTEMBER, 21)
383         assertEquals(
384             "Sep 43 BC", // There is no year 0, so -42 means 43 BC
385             DateTimeFormatter(appContext, builder.build(), Locale.US).format(dateBc)
386         )
387 
388         assertEquals(
389             "Sep 2021 AD",
390             DateTimeFormatter(appContext, builder.build(), Locale.US).format(testDate)
391         )
392 
393         assertEquals(
394             if (isIcuAvailable) "Sep 2021 Anno Domini" else "Sep 2021 AD",
395             DateTimeFormatter(
396                     appContext,
397                     builder.setEra(SkeletonOptions.Era.WIDE).build(),
398                     Locale.US
399                 )
400                 .format(testDate)
401         )
402     }
403 
404     @Test
405     @SmallTest
testWeekDaynull406     fun testWeekDay() {
407         val builder =
408             SkeletonOptions.Builder()
409                 .setYear(SkeletonOptions.Year.NUMERIC)
410                 .setMonth(SkeletonOptions.Month.WIDE)
411                 .setDay(SkeletonOptions.Day.NUMERIC)
412                 .setWeekDay(SkeletonOptions.WeekDay.ABBREVIATED)
413 
414         assertEquals(
415             "Sun, September 19, 2021",
416             DateTimeFormatter(appContext, builder.build(), Locale.US).format(testDate)
417         )
418     }
419 
420     @Test
421     @SmallTest
testTimeZonenull422     fun testTimeZone() {
423         val builder =
424             SkeletonOptions.Builder()
425                 .setHour(SkeletonOptions.Hour.NUMERIC)
426                 .setMinute(SkeletonOptions.Minute.NUMERIC)
427                 .setTimezone(SkeletonOptions.Timezone.LONG)
428         val locale = Locale.US
429 
430         val timeZone = TimeZone.getTimeZone("America/Denver")
431         val coloradoTime = Calendar.getInstance(timeZone, locale)
432         coloradoTime.set(
433             2021,
434             Calendar.AUGUST,
435             19, // Date
436             21,
437             42,
438             12
439         ) // Time
440 
441         var options = builder.build()
442         assertEquals(
443             when {
444                 Build.VERSION.SDK_INT >= 34 -> "9:42\u202FPM Mountain Daylight Time"
445                 isIcuAvailable -> "9:42 PM Mountain Daylight Time"
446                 else -> "8:42 PM Pacific Daylight Time"
447             },
448             DateTimeFormatter(appContext, options, locale).format(coloradoTime)
449         )
450 
451         options = builder.setTimezone(SkeletonOptions.Timezone.SHORT).build()
452         assertEquals(
453             when {
454                 Build.VERSION.SDK_INT >= 34 -> "9:42\u202FPM MDT"
455                 isIcuAvailable -> "9:42 PM MDT"
456                 else -> "8:42 PM PDT"
457             },
458             DateTimeFormatter(appContext, options, locale).format(coloradoTime)
459         )
460 
461         options = builder.setTimezone(SkeletonOptions.Timezone.SHORT_GENERIC).build()
462         assertEquals(
463             when {
464                 Build.VERSION.SDK_INT >= 34 -> "9:42\u202FPM MT"
465                 isIcuAvailable -> "9:42 PM MT"
466                 else -> "8:42 PM PDT"
467             },
468             DateTimeFormatter(appContext, options, locale).format(coloradoTime)
469         )
470 
471         options = builder.setTimezone(SkeletonOptions.Timezone.SHORT_OFFSET).build()
472         assertEquals(
473             when {
474                 Build.VERSION.SDK_INT >= 34 -> "9:42\u202FPM GMT-6"
475                 isIcuAvailable -> "9:42 PM GMT-6"
476                 else -> "8:42 PM PDT"
477             },
478             DateTimeFormatter(appContext, options, locale).format(coloradoTime)
479         )
480     }
481 
482     @Test
483     @SmallTest
484     // Making sure the APIs honor the default timezone
testDefaultTimeZonenull485     fun testDefaultTimeZone() {
486         val options =
487             SkeletonOptions.Builder()
488                 .setHour(SkeletonOptions.Hour.NUMERIC)
489                 .setMinute(SkeletonOptions.Minute.NUMERIC)
490                 .setTimezone(SkeletonOptions.Timezone.LONG)
491                 .build()
492         val locale = Locale.US
493 
494         // Honor the current default timezone
495         val expPDT =
496             when {
497                 Build.VERSION.SDK_INT >= 34 -> "9:42\u202FPM Pacific Daylight Time"
498                 else -> "9:42 PM Pacific Daylight Time"
499             }
500         // Test Calendar, Date, and milliseconds
501         assertEquals(expPDT, DateTimeFormatter(appContext, options, locale).format(testCalendar))
502         assertEquals(expPDT, DateTimeFormatter(appContext, options, locale).format(testDate))
503         assertEquals(expPDT, DateTimeFormatter(appContext, options, locale).format(testMillis))
504 
505         // Change the default timezone.
506         TimeZone.setDefault(TimeZone.getTimeZone("America/Denver"))
507         val expMDT =
508             when {
509                 Build.VERSION.SDK_INT >= 34 -> "10:42\u202FPM Mountain Daylight Time"
510                 else -> "10:42 PM Mountain Daylight Time"
511             }
512         // The calendar object already has a time zone of its own, captured at creation time.
513         // So not matching the default changed after is the expected behavior.
514         // BUT!
515         // Below N there is no ICU, so we fallback to `java.text.DateFormat`.
516         // Which can't format a Calendar, only Date and Number (millisecond, epoch time)
517         // So the time zone info is lost below N. It is the best we can do.
518         val expCal = if (isIcuAvailable) expPDT else expMDT
519         assertEquals(expCal, DateTimeFormatter(appContext, options, locale).format(testCalendar))
520         assertEquals(expMDT, DateTimeFormatter(appContext, options, locale).format(testDate))
521         assertEquals(expMDT, DateTimeFormatter(appContext, options, locale).format(testMillis))
522         // The default timezone is restored in @Before, no need to change it back
523     }
524 
525     @Test
526     @SmallTest
testEmptySkeletonnull527     fun testEmptySkeleton() {
528         val options = SkeletonOptions.fromString("")
529         assertEquals("", DateTimeFormatter(appContext, options, Locale.US).format(testDate))
530     }
531 
532     @Test
533     @SmallTest
testInvalidSkeletonField_throwsIAEnull534     fun testInvalidSkeletonField_throwsIAE() {
535         assertFailsWith<IllegalArgumentException> { SkeletonOptions.fromString("fiInNopPRtT") }
536     }
537 }
538