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