From a0fc08e883803c8a49fede58bd4d512590f59254 Mon Sep 17 00:00:00 2001 From: Matt Johnson-Pint Date: Tue, 23 Mar 2021 18:34:09 -0700 Subject: [PATCH] Improve time zone display names on Unix (#48931) --- .../Interop.TimeZoneDisplayNameType.cs | 2 + .../pal_icushim_internal.h | 16 +- .../pal_icushim_internal_android.h | 23 +- .../pal_timeZoneInfo.c | 320 ++++++++++++++++-- .../pal_timeZoneInfo.h | 9 +- .../src/System/TimeZoneInfo.GetDisplayName.cs | 7 +- .../src/System/TimeZoneInfo.Unix.cs | 231 +++++++++++-- .../src/System/TimeZoneInfo.Win32.cs | 38 +++ .../src/System/TimeZoneInfo.cs | 15 +- .../tests/BinaryFormatterTests.cs | 12 + .../tests/System/TimeZoneInfoTests.cs | 205 ++++++++--- 11 files changed, 758 insertions(+), 120 deletions(-) diff --git a/src/libraries/Common/src/Interop/Interop.TimeZoneDisplayNameType.cs b/src/libraries/Common/src/Interop/Interop.TimeZoneDisplayNameType.cs index f46072196eb91..570eb0eb4b1ec 100644 --- a/src/libraries/Common/src/Interop/Interop.TimeZoneDisplayNameType.cs +++ b/src/libraries/Common/src/Interop/Interop.TimeZoneDisplayNameType.cs @@ -11,6 +11,8 @@ internal enum TimeZoneDisplayNameType Generic = 0, Standard = 1, DaylightSavings = 2, + GenericLocation = 3, + ExemplarCity = 4, } } } diff --git a/src/libraries/Native/Unix/System.Globalization.Native/pal_icushim_internal.h b/src/libraries/Native/Unix/System.Globalization.Native/pal_icushim_internal.h index 836ba44ec182f..b70e31fe9c0bb 100644 --- a/src/libraries/Native/Unix/System.Globalization.Native/pal_icushim_internal.h +++ b/src/libraries/Native/Unix/System.Globalization.Native/pal_icushim_internal.h @@ -58,27 +58,33 @@ // (U_ICU_VERSION_MAJOR_NUM < 52) // The following APIs are not supported in the ICU versions less than 52. We need to define them manually. // We have to do runtime check before using the pointers to these APIs. That is why these are listed in the FOR_ALL_OPTIONAL_ICU_FUNCTIONS list. -U_CAPI int32_t U_EXPORT2 ucal_getWindowsTimeZoneID(const UChar* id, int32_t len,UChar* winid, int32_t winidCapacity, UErrorCode* status); U_CAPI int32_t U_EXPORT2 ucal_getTimeZoneIDForWindowsID(const UChar* winid, int32_t len, const char* region, UChar* id, int32_t idCapacity, UErrorCode* status); +U_CAPI int32_t U_EXPORT2 ucal_getWindowsTimeZoneID(const UChar* id, int32_t len, UChar* winid, int32_t winidCapacity, UErrorCode* status); #endif // List of all functions from the ICU libraries that are used in the System.Globalization.Native.so #define FOR_ALL_UNCONDITIONAL_ICU_FUNCTIONS \ PER_FUNCTION_BLOCK(u_charsToUChars, libicuuc, true) \ PER_FUNCTION_BLOCK(u_getVersion, libicuuc, true) \ + PER_FUNCTION_BLOCK(u_strcmp, libicuuc, true) \ + PER_FUNCTION_BLOCK(u_strcpy, libicuuc, true) \ PER_FUNCTION_BLOCK(u_strlen, libicuuc, true) \ PER_FUNCTION_BLOCK(u_strncpy, libicuuc, true) \ PER_FUNCTION_BLOCK(u_tolower, libicuuc, true) \ PER_FUNCTION_BLOCK(u_toupper, libicuuc, true) \ + PER_FUNCTION_BLOCK(u_uastrcpy, libicuuc, true) \ PER_FUNCTION_BLOCK(ucal_add, libicui18n, true) \ PER_FUNCTION_BLOCK(ucal_close, libicui18n, true) \ PER_FUNCTION_BLOCK(ucal_get, libicui18n, true) \ PER_FUNCTION_BLOCK(ucal_getAttribute, libicui18n, true) \ PER_FUNCTION_BLOCK(ucal_getKeywordValuesForLocale, libicui18n, true) \ PER_FUNCTION_BLOCK(ucal_getLimit, libicui18n, true) \ + PER_FUNCTION_BLOCK(ucal_getNow, libicui18n, true) \ PER_FUNCTION_BLOCK(ucal_getTimeZoneDisplayName, libicui18n, true) \ PER_FUNCTION_BLOCK(ucal_open, libicui18n, true) \ + PER_FUNCTION_BLOCK(ucal_openTimeZoneIDEnumeration, libicui18n, true) \ PER_FUNCTION_BLOCK(ucal_set, libicui18n, true) \ + PER_FUNCTION_BLOCK(ucal_setMillis, libicui18n, true) \ PER_FUNCTION_BLOCK(ucol_close, libicui18n, true) \ PER_FUNCTION_BLOCK(ucol_closeElements, libicui18n, true) \ PER_FUNCTION_BLOCK(ucol_getOffset, libicui18n, true) \ @@ -96,6 +102,7 @@ U_CAPI int32_t U_EXPORT2 ucal_getTimeZoneIDForWindowsID(const UChar* winid, int3 PER_FUNCTION_BLOCK(ucol_strcoll, libicui18n, true) \ PER_FUNCTION_BLOCK(udat_close, libicui18n, true) \ PER_FUNCTION_BLOCK(udat_countSymbols, libicui18n, true) \ + PER_FUNCTION_BLOCK(udat_format, libicui18n, true) \ PER_FUNCTION_BLOCK(udat_getSymbols, libicui18n, true) \ PER_FUNCTION_BLOCK(udat_open, libicui18n, true) \ PER_FUNCTION_BLOCK(udat_setCalendar, libicui18n, true) \ @@ -202,21 +209,27 @@ FOR_ALL_ICU_FUNCTIONS // to the functions of the selected version of ICU in the initialization. #define u_charsToUChars(...) u_charsToUChars_ptr(__VA_ARGS__) #define u_getVersion(...) u_getVersion_ptr(__VA_ARGS__) +#define u_strcmp(...) u_strcmp_ptr(__VA_ARGS__) +#define u_strcpy(...) u_strcpy_ptr(__VA_ARGS__) #define u_strlen(...) u_strlen_ptr(__VA_ARGS__) #define u_strncpy(...) u_strncpy_ptr(__VA_ARGS__) #define u_tolower(...) u_tolower_ptr(__VA_ARGS__) #define u_toupper(...) u_toupper_ptr(__VA_ARGS__) +#define u_uastrcpy(...) u_uastrcpy_ptr(__VA_ARGS__) #define ucal_add(...) ucal_add_ptr(__VA_ARGS__) #define ucal_close(...) ucal_close_ptr(__VA_ARGS__) #define ucal_get(...) ucal_get_ptr(__VA_ARGS__) #define ucal_getAttribute(...) ucal_getAttribute_ptr(__VA_ARGS__) #define ucal_getKeywordValuesForLocale(...) ucal_getKeywordValuesForLocale_ptr(__VA_ARGS__) #define ucal_getLimit(...) ucal_getLimit_ptr(__VA_ARGS__) +#define ucal_getNow(...) ucal_getNow_ptr(__VA_ARGS__) #define ucal_getTimeZoneDisplayName(...) ucal_getTimeZoneDisplayName_ptr(__VA_ARGS__) #define ucal_getTimeZoneIDForWindowsID(...) ucal_getTimeZoneIDForWindowsID_ptr(__VA_ARGS__) #define ucal_getWindowsTimeZoneID(...) ucal_getWindowsTimeZoneID_ptr(__VA_ARGS__) #define ucal_open(...) ucal_open_ptr(__VA_ARGS__) +#define ucal_openTimeZoneIDEnumeration(...) ucal_openTimeZoneIDEnumeration_ptr(__VA_ARGS__) #define ucal_set(...) ucal_set_ptr(__VA_ARGS__) +#define ucal_setMillis(...) ucal_setMillis_ptr(__VA_ARGS__) #define ucol_close(...) ucol_close_ptr(__VA_ARGS__) #define ucol_closeElements(...) ucol_closeElements_ptr(__VA_ARGS__) #define ucol_getOffset(...) ucol_getOffset_ptr(__VA_ARGS__) @@ -241,6 +254,7 @@ FOR_ALL_ICU_FUNCTIONS #define ucurr_getName(...) ucurr_getName_ptr(__VA_ARGS__) #define udat_close(...) udat_close_ptr(__VA_ARGS__) #define udat_countSymbols(...) udat_countSymbols_ptr(__VA_ARGS__) +#define udat_format(...) udat_format_ptr(__VA_ARGS__) #define udat_getSymbols(...) udat_getSymbols_ptr(__VA_ARGS__) #define udat_open(...) udat_open_ptr(__VA_ARGS__) #define udat_setCalendar(...) udat_setCalendar_ptr(__VA_ARGS__) diff --git a/src/libraries/Native/Unix/System.Globalization.Native/pal_icushim_internal_android.h b/src/libraries/Native/Unix/System.Globalization.Native/pal_icushim_internal_android.h index 7e05fa35d03ca..fea4c8696f785 100644 --- a/src/libraries/Native/Unix/System.Globalization.Native/pal_icushim_internal_android.h +++ b/src/libraries/Native/Unix/System.Globalization.Native/pal_icushim_internal_android.h @@ -25,6 +25,7 @@ typedef struct UBreakIterator UBreakIterator; typedef int8_t UBool; typedef uint16_t UChar; typedef int32_t UChar32; +typedef double UDate; typedef uint8_t UVersionInfo[U_MAX_VERSION_LENGTH]; typedef void* UNumberFormat; @@ -369,6 +370,12 @@ typedef enum UCollationResult { UCOL_LESS = -1 } UCollationResult; +typedef enum USystemTimeZoneType { + UCAL_ZONE_TYPE_ANY, + UCAL_ZONE_TYPE_CANONICAL, + UCAL_ZONE_TYPE_CANONICAL_LOCATION +} USystemTimeZoneType; + enum { UIDNA_ERROR_EMPTY_LABEL = 1, UIDNA_ERROR_LABEL_TOO_LONG = 2, @@ -419,25 +426,36 @@ typedef struct UIDNAInfo { int32_t reservedI3; } UIDNAInfo; +typedef struct UFieldPosition { + int32_t field; + int32_t beginIndex; + int32_t endIndex; +} UFieldPosition; void u_charsToUChars(const char * cs, UChar * us, int32_t length); void u_getVersion(UVersionInfo versionArray); int32_t u_strlen(const UChar * s); +int32_t u_strcmp(const UChar * s1, const UChar * s2); +UChar * u_strcpy(UChar * dst, const UChar * src); UChar * u_strncpy(UChar * dst, const UChar * src, int32_t n); UChar32 u_tolower(UChar32 c); UChar32 u_toupper(UChar32 c); +UChar* u_uastrcpy(UChar * dst, const char * src); void ucal_add(UCalendar * cal, UCalendarDateFields field, int32_t amount, UErrorCode * status); void ucal_close(UCalendar * cal); int32_t ucal_get(const UCalendar * cal, UCalendarDateFields field, UErrorCode * status); int32_t ucal_getAttribute(const UCalendar * cal, UCalendarAttribute attr); UEnumeration * ucal_getKeywordValuesForLocale(const char * key, const char * locale, UBool commonlyUsed, UErrorCode * status); int32_t ucal_getLimit(const UCalendar * cal, UCalendarDateFields field, UCalendarLimitType type, UErrorCode * status); +UDate ucal_getNow(void); int32_t ucal_getTimeZoneDisplayName(const UCalendar * cal, UCalendarDisplayNameType type, const char * locale, UChar * result, int32_t resultLength, UErrorCode * status); -UCalendar * ucal_open(const UChar * zoneID, int32_t len, const char * locale, UCalendarType type, UErrorCode * status); -void ucal_set(UCalendar * cal, UCalendarDateFields field, int32_t value); int32_t ucal_getTimeZoneIDForWindowsID(const UChar * winid, int32_t len, const char * region, UChar * id, int32_t idCapacity, UErrorCode * status); int32_t ucal_getWindowsTimeZoneID(const UChar * id, int32_t len, UChar * winid, int32_t winidCapacity, UErrorCode * status); +UCalendar * ucal_open(const UChar * zoneID, int32_t len, const char * locale, UCalendarType type, UErrorCode * status); +UEnumeration * ucal_openTimeZoneIDEnumeration(USystemTimeZoneType zoneType, const char * region, const int32_t * rawOffset, UErrorCode * ec); +void ucal_set(UCalendar * cal, UCalendarDateFields field, int32_t value); +void ucal_setMillis(UCalendar * cal, UDate dateTime, UErrorCode * status); void ucol_close(UCollator * coll); void ucol_closeElements(UCollationElements * elems); int32_t ucol_getOffset(const UCollationElements *elems); @@ -457,6 +475,7 @@ int32_t ucurr_forLocale(const char * locale, UChar * buff, int32_t buffCapacity, const UChar * ucurr_getName(const UChar * currency, const char * locale, UCurrNameStyle nameStyle, UBool * isChoiceFormat, int32_t * len, UErrorCode * ec); void udat_close(UDateFormat * format); int32_t udat_countSymbols(const UDateFormat * fmt, UDateFormatSymbolType type); +int32_t udat_format(const UDateFormat * format, UDate dateToFormat, UChar * result, int32_t resultLength, UFieldPosition * position, UErrorCode * status); int32_t udat_getSymbols(const UDateFormat * fmt, UDateFormatSymbolType type, int32_t symbolIndex, UChar * result, int32_t resultLength, UErrorCode * status); UDateFormat * udat_open(UDateFormatStyle timeStyle, UDateFormatStyle dateStyle, const char * locale, const UChar * tzID, int32_t tzIDLength, const UChar * pattern, int32_t patternLength, UErrorCode * status); void udat_setCalendar(UDateFormat * fmt, const UCalendar * calendarToSet); diff --git a/src/libraries/Native/Unix/System.Globalization.Native/pal_timeZoneInfo.c b/src/libraries/Native/Unix/System.Globalization.Native/pal_timeZoneInfo.c index 09c2c1ca5092b..9173931716c0e 100644 --- a/src/libraries/Native/Unix/System.Globalization.Native/pal_timeZoneInfo.c +++ b/src/libraries/Native/Unix/System.Globalization.Native/pal_timeZoneInfo.c @@ -4,41 +4,19 @@ #include #include +#include #include "pal_errors_internal.h" #include "pal_locale_internal.h" #include "pal_timeZoneInfo.h" -/* -Gets the localized display name for the specified time zone. -*/ -ResultCode GlobalizationNative_GetTimeZoneDisplayName(const UChar* localeName, - const UChar* timeZoneId, - TimeZoneDisplayNameType type, - UChar* result, - int32_t resultLength) -{ - UErrorCode err = U_ZERO_ERROR; - char locale[ULOC_FULLNAME_CAPACITY]; - GetLocale(localeName, locale, ULOC_FULLNAME_CAPACITY, false, &err); - - int32_t timeZoneIdLength = -1; // timeZoneId is NULL-terminated - UCalendar* calendar = ucal_open(timeZoneId, timeZoneIdLength, locale, UCAL_DEFAULT, &err); +#define DISPLAY_NAME_LENGTH 256 // arbitrarily large, to be safe +#define TZID_LENGTH 64 // arbitrarily large, to be safe - // TODO (https://github.com/dotnet/runtime/issues/16232): need to support Generic names, but ICU "C" api - // has no public option for this. For now, just use the ICU standard name for both Standard and Generic - // (which is the same behavior on Windows with the mincore TIME_ZONE_INFORMATION APIs). - ucal_getTimeZoneDisplayName( - calendar, - type == TimeZoneDisplayName_DaylightSavings ? UCAL_DST : UCAL_STANDARD, - locale, - result, - resultLength, - &err); - - ucal_close(calendar); - return GetResultCode(err); -} +// For descriptions of the following patterns, see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#date-field-symbol-table +static const UChar GENERIC_PATTERN_UCHAR[] = {'v', 'v', 'v', 'v', '\0'}; // u"vvvv" +static const UChar GENERIC_LOCATION_PATTERN_UCHAR[] = {'V', 'V', 'V', 'V', '\0'}; // u"VVVV" +static const UChar EXEMPLAR_CITY_PATTERN_UCHAR[] = {'V', 'V', 'V', '\0'}; // u"VVV" /* Convert Windows Time Zone Id to IANA Id @@ -80,3 +58,287 @@ int32_t GlobalizationNative_IanaIdToWindowsId(const UChar* ianaId, UChar* window // Failed return 0; } + +/* +Private function to get the standard and daylight names from the ICU Calendar API. +*/ +static void GetTimeZoneDisplayName_FromCalendar(const char* locale, const UChar* timeZoneId, const UDate timestamp, UCalendarDisplayNameType type, UChar* result, int32_t resultLength, UErrorCode* err) +{ + // Examples: "Pacific Standard Time" (standard) + // "Pacific Daylight Time" (daylight) + + // (-1 == timeZoneId is null terminated) + UCalendar* calendar = ucal_open(timeZoneId, -1, locale, UCAL_DEFAULT, err); + if (U_SUCCESS(*err)) + { + ucal_setMillis(calendar, timestamp, err); + if (U_SUCCESS(*err)) + { + ucal_getTimeZoneDisplayName(calendar, type, locale, result, resultLength, err); + } + + ucal_close(calendar); + } +} + +/* +Private function to get the various forms of generic time zone names using patterns with the ICU Date Formatting API. +*/ +static void GetTimeZoneDisplayName_FromPattern(const char* locale, const UChar* timeZoneId, const UDate timestamp, const UChar* pattern, UChar* result, int32_t resultLength, UErrorCode* err) +{ + // (-1 == timeZoneId and pattern are null terminated) + UDateFormat* dateFormatter = udat_open(UDAT_PATTERN, UDAT_PATTERN, locale, timeZoneId, -1, pattern, -1, err); + if (U_SUCCESS(*err)) + { + udat_format(dateFormatter, timestamp, result, resultLength, NULL, err); + udat_close(dateFormatter); + } +} + +/* +Private function to modify the generic display name to better suit our needs. +*/ +static void FixupTimeZoneGenericDisplayName(const char* locale, const UChar* timeZoneId, const UDate timestamp, UChar* genericName, UErrorCode* err) +{ + // By default, some time zones will still give a standard name instead of the generic + // non-location name. + // + // For example, given the following zones and their English results: + // America/Denver => "Mountain Time" + // America/Phoenix => "Mountain Standard Time" + // + // We prefer that all time zones in the same metazone have the same generic name, + // such that they are grouped together when combined with their base offset, location + // and sorted alphabetically. For example: + // + // (UTC-07:00) Mountain Time (Denver) + // (UTC-07:00) Mountain Time (Phoenix) + // + // Without modification, they would show as: + // + // (UTC-07:00) Mountain Standard Time (Phoenix) + // (UTC-07:00) Mountain Time (Denver) + // + // When combined with the rest of the time zones, having them not grouped together + // makes it harder to locate the correct time zone from a list. + // + // The reason we get the standard name is because TR35 (LDML) defines a rule that + // states that metazone generic names should use standard names if there is no DST + // transition within a +/- 184 day range near the timestamp being translated. + // + // See the "Type Fallback" section in: + // https://www.unicode.org/reports/tr35/tr35-dates.html#Using_Time_Zone_Names + // + // This might make sense when attached to an exact timestamp, but doesn't work well + // when using the generic name to pick a time zone from a list. + // Note that this test only happens when the generic name comes from a metazone. + // + // ICU implements this test in TZGNCore::formatGenericNonLocationName in + // https://github.com/unicode-org/icu/blob/master/icu4c/source/i18n/tzgnames.cpp + // (Note the kDstCheckRange 184-day constant.) + // + // The rest of the code below is a workaround for this issue. When the generic + // name and standard name match, we search through the other time zones for one + // having the same base offset and standard name but a shorter generic name. + // That will at least keep them grouped together, though note that if there aren't + // any found that means all of them are using the standard name. + // + // If ICU ever adds an API to get a generic name that doesn't perform the + // 184-day check on metazone names, then test for the existence of that new API + // and use that instead of this workaround. Keep the workaround for when the + // new API is not available. + + // Get the standard name for this time zone. (-1 == timeZoneId is null terminated) + // Note that we leave the calendar open and close it later so we can also get the base offset. + UChar standardName[DISPLAY_NAME_LENGTH]; + UCalendar* calendar = ucal_open(timeZoneId, -1, locale, UCAL_DEFAULT, err); + if (U_FAILURE(*err)) + { + return; + } + + ucal_setMillis(calendar, timestamp, err); + if (U_FAILURE(*err)) + { + ucal_close(calendar); + return; + } + + ucal_getTimeZoneDisplayName(calendar, UCAL_STANDARD, locale, standardName, DISPLAY_NAME_LENGTH, err); + if (U_FAILURE(*err)) + { + ucal_close(calendar); + return; + } + + // Ensure the generic name is the same as the standard name. + if (u_strcmp(genericName, standardName) != 0) + { + ucal_close(calendar); + return; + } + + // Get some details for later comparison. + const int32_t originalGenericNameActualLength = u_strlen(genericName); + const int32_t baseOffset = ucal_get(calendar, UCAL_ZONE_OFFSET, err); + if (U_FAILURE(*err)) + { + ucal_close(calendar); + return; + } + + // Allocate some additional strings for test values. + UChar testTimeZoneId[TZID_LENGTH]; + UChar testDisplayName[DISPLAY_NAME_LENGTH]; + UChar testDisplayName2[DISPLAY_NAME_LENGTH]; + + // Enumerate over all the time zones having the same base offset. + UEnumeration* pEnum = ucal_openTimeZoneIDEnumeration(UCAL_ZONE_TYPE_CANONICAL_LOCATION, NULL, &baseOffset, err); + if (U_FAILURE(*err)) + { + uenum_close(pEnum); + ucal_close(calendar); + return; + } + + int count = uenum_count(pEnum, err); + if (U_FAILURE(*err)) + { + uenum_close(pEnum); + ucal_close(calendar); + return; + } + + for (int i = 0; i < count; i++) + { + // Get a time zone id from the enumeration to test with. + int32_t testIdLength; + const char* testId = uenum_next(pEnum, &testIdLength, err); + if (U_FAILURE(*err)) + { + // There shouldn't be a failure in enumeration, but if there was then exit. + uenum_close(pEnum); + ucal_close(calendar); + return; + } + + // Make a UChar[] version of the test time zone id for use in the API calls. + u_uastrcpy(testTimeZoneId, testId); + + // Get the standard name from the test time zone. + GetTimeZoneDisplayName_FromCalendar(locale, testTimeZoneId, timestamp, UCAL_STANDARD, testDisplayName, DISPLAY_NAME_LENGTH, err); + if (U_FAILURE(*err)) + { + // Failed, but keep trying through the rest of the loop in case the failure is specific to this test zone. + continue; + } + + // See if the test time zone has a different standard name. + if (u_strcmp(testDisplayName, standardName) != 0) + { + // It has a different standard name. We can't use it. + continue; + } + + // Get the generic name from the test time zone. + GetTimeZoneDisplayName_FromPattern(locale, testTimeZoneId, timestamp, GENERIC_PATTERN_UCHAR, testDisplayName, DISPLAY_NAME_LENGTH, err); + if (U_FAILURE(*err)) + { + // Failed, but keep trying through the rest of the loop in case the failure is specific to this test zone. + continue; + } + + // See if the test time zone has a longer (or same size) generic name. + if (u_strlen(testDisplayName) >= originalGenericNameActualLength) + { + // The test time zone's generic name isn't any shorter than the one we already have. + continue; + } + + // We probably have found a better generic name. But just to be safe, make sure the test zone isn't + // using a generic name that is specific to a particular location. For example, "Antarctica/Troll" + // uses "Troll Time" as a generic name, but "Greenwich Mean Time" as a standard name. We don't + // want other zones that use "Greenwich Mean Time" to be labeled as "Troll Time". + + GetTimeZoneDisplayName_FromPattern(locale, testTimeZoneId, timestamp, GENERIC_LOCATION_PATTERN_UCHAR, testDisplayName2, DISPLAY_NAME_LENGTH, err); + if (U_FAILURE(*err)) + { + // Failed, but keep trying through the rest of the loop in case the failure is specific to this test zone. + continue; + } + + if (u_strcmp(testDisplayName, testDisplayName2) != 0) + { + // We have found a better generic name. Use it. + u_strcpy(genericName, testDisplayName); + break; + } + } + + uenum_close(pEnum); + ucal_close(calendar); +} + +/* +Gets the localized display name that is currently in effect for the specified time zone. +*/ +ResultCode GlobalizationNative_GetTimeZoneDisplayName(const UChar* localeName, const UChar* timeZoneId, TimeZoneDisplayNameType type, UChar* result, int32_t resultLength) +{ + UErrorCode err = U_ZERO_ERROR; + char locale[ULOC_FULLNAME_CAPACITY]; + GetLocale(localeName, locale, ULOC_FULLNAME_CAPACITY, false, &err); + if (U_FAILURE(err)) + { + return GetResultCode(err); + } + + // Note: Due to how CLDR Metazones work, a past or future timestamp might use a different set of display names + // than are currently in effect. + // + // See https://github.com/unicode-org/cldr/blob/master/common/supplemental/metaZones.xml + // + // Example: As of writing this, Africa/Algiers is in the Europe_Central metazone, + // which has a standard-time name of "Central European Standard Time" (in English). + // However, in some previous dates, it used the Europe_Western metazone, + // having the standard-time name of "Western European Standard Time" (in English). + // Only the *current* name will be returned. + // + // TODO: Add a parameter for the timestamp that is used when getting the display names instead of + // getting "now" on the following line. Everything else should be using this timestamp. + // For now, since TimeZoneInfo presently uses only a single set of display names, we will + // use the names associated with the *current* date and time. + + UDate timestamp = ucal_getNow(); + + switch (type) + { + case TimeZoneDisplayName_Standard: + GetTimeZoneDisplayName_FromCalendar(locale, timeZoneId, timestamp, UCAL_STANDARD, result, resultLength, &err); + break; + + case TimeZoneDisplayName_DaylightSavings: + GetTimeZoneDisplayName_FromCalendar(locale, timeZoneId, timestamp, UCAL_DST, result, resultLength, &err); + break; + + case TimeZoneDisplayName_Generic: + GetTimeZoneDisplayName_FromPattern(locale, timeZoneId, timestamp, GENERIC_PATTERN_UCHAR, result, resultLength, &err); + if (U_SUCCESS(err)) + { + FixupTimeZoneGenericDisplayName(locale, timeZoneId, timestamp, result, &err); + } + break; + + case TimeZoneDisplayName_GenericLocation: + GetTimeZoneDisplayName_FromPattern(locale, timeZoneId, timestamp, GENERIC_LOCATION_PATTERN_UCHAR, result, resultLength, &err); + break; + + case TimeZoneDisplayName_ExemplarCity: + GetTimeZoneDisplayName_FromPattern(locale, timeZoneId, timestamp, EXEMPLAR_CITY_PATTERN_UCHAR, result, resultLength, &err); + break; + + default: + return UnknownError; + } + + return GetResultCode(err); +} diff --git a/src/libraries/Native/Unix/System.Globalization.Native/pal_timeZoneInfo.h b/src/libraries/Native/Unix/System.Globalization.Native/pal_timeZoneInfo.h index c9fe188979c11..49bfb6250eb50 100644 --- a/src/libraries/Native/Unix/System.Globalization.Native/pal_timeZoneInfo.h +++ b/src/libraries/Native/Unix/System.Globalization.Native/pal_timeZoneInfo.h @@ -16,13 +16,10 @@ typedef enum TimeZoneDisplayName_Generic = 0, TimeZoneDisplayName_Standard = 1, TimeZoneDisplayName_DaylightSavings = 2, + TimeZoneDisplayName_GenericLocation = 3, + TimeZoneDisplayName_ExemplarCity = 4, } TimeZoneDisplayNameType; -PALEXPORT ResultCode GlobalizationNative_GetTimeZoneDisplayName(const UChar* localeName, - const UChar* timeZoneId, - TimeZoneDisplayNameType type, - UChar* result, - int32_t resultLength); - PALEXPORT int32_t GlobalizationNative_WindowsIdToIanaId(const UChar* windowsId, UChar* ianaId, int32_t ianaIdLength); PALEXPORT int32_t GlobalizationNative_IanaIdToWindowsId(const UChar* ianaId, UChar* windowsId, int32_t windowsIdLength); +PALEXPORT ResultCode GlobalizationNative_GetTimeZoneDisplayName(const UChar* localeName, const UChar* timeZoneId, TimeZoneDisplayNameType type, UChar* result, int32_t resultLength); diff --git a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.GetDisplayName.cs b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.GetDisplayName.cs index 834b719fefe2c..ba54330cf8919 100644 --- a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.GetDisplayName.cs +++ b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.GetDisplayName.cs @@ -17,11 +17,10 @@ namespace System { public sealed partial class TimeZoneInfo { - private unsafe void GetDisplayName(Interop.Globalization.TimeZoneDisplayNameType nameType, string uiCulture, ref string? displayName) + private static unsafe void GetDisplayName(string timeZoneId, Interop.Globalization.TimeZoneDisplayNameType nameType, string uiCulture, ref string? displayName) { if (GlobalizationMode.Invariant) { - displayName = _standardDisplayName; return; } @@ -35,7 +34,7 @@ private unsafe void GetDisplayName(Interop.Globalization.TimeZoneDisplayNameType } }, uiCulture, - _id, + timeZoneId, nameType, out timeZoneDisplayName); @@ -51,7 +50,7 @@ private unsafe void GetDisplayName(Interop.Globalization.TimeZoneDisplayNameType } }, FallbackCultureName, - _id, + timeZoneId, nameType, out timeZoneDisplayName); } diff --git a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Unix.cs b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Unix.cs index 44c49e52de0b1..3a5899c6c2c24 100644 --- a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Unix.cs +++ b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Unix.cs @@ -23,9 +23,47 @@ public sealed partial class TimeZoneInfo private const string TimeZoneEnvironmentVariable = "TZ"; private const string TimeZoneDirectoryEnvironmentVariable = "TZDIR"; private const string FallbackCultureName = "en-US"; + private const string GmtId = "GMT"; + + // UTC aliases per https://github.com/unicode-org/cldr/blob/master/common/bcp47/timezone.xml + // Hard-coded because we need to treat all aliases of UTC the same even when ICU is not available, + // or when we get "GMT" returned from older ICU versions. (This list is not likely to change.) + private static readonly string[] s_UtcAliases = new[] { + "Etc/UTC", + "Etc/UCT", + "Etc/Universal", + "Etc/Zulu", + "UCT", + "UTC", + "Universal", + "Zulu" + }; + + // Some time zones may give better display names using their location names rather than their generic name. + // We can update this list as need arises. + private static readonly string[] s_ZonesThatUseLocationName = new[] { + "Europe/Minsk", // Prefer "Belarus Time" over "Moscow Standard Time (Minsk)" + "Europe/Moscow", // Prefer "Moscow Time" over "Moscow Standard Time" + "Europe/Simferopol", // Prefer "Simferopol Time" over "Moscow Standard Time (Simferopol)" + "Pacific/Apia", // Prefer "Samoa Time" over "Apia Time" + "Pacific/Pitcairn" // Prefer "Pitcairn Islands Time" over "Pitcairn Time" + }; private TimeZoneInfo(byte[] data, string id, bool dstDisabled) { + _id = id; + + // Handle UTC and its aliases + if (StringArrayContains(_id, s_UtcAliases, StringComparison.OrdinalIgnoreCase)) + { + _standardDisplayName = GetUtcStandardDisplayName(); + _daylightDisplayName = _standardDisplayName; + _displayName = $"(UTC) {_standardDisplayName}"; + _baseUtcOffset = TimeSpan.Zero; + _adjustmentRules = Array.Empty(); + return; + } + TZifHead t; DateTime[] dts; byte[] typeOfLocalTime; @@ -40,12 +78,8 @@ private TimeZoneInfo(byte[] data, string id, bool dstDisabled) // parse the raw TZif bytes; this method can throw ArgumentException when the data is malformed. TZif_ParseRaw(data, out t, out dts, out typeOfLocalTime, out transitionType, out zoneAbbreviations, out StandardTime, out GmtTime, out futureTransitionsPosixFormat); - _id = id; - _displayName = LocalId; - _baseUtcOffset = TimeSpan.Zero; - // find the best matching baseUtcOffset and display strings based on the current utcNow value. - // NOTE: read the display strings from the tzfile now in case they can't be loaded later + // NOTE: read the Standard and Daylight display strings from the tzfile now in case they can't be loaded later // from the globalization data. DateTime utcNow = DateTime.UtcNow; for (int i = 0; i < dts.Length && dts[i] <= utcNow; i++) @@ -82,21 +116,18 @@ private TimeZoneInfo(byte[] data, string id, bool dstDisabled) // Use abbrev as the fallback _standardDisplayName = standardAbbrevName; - _daylightDisplayName = daylightAbbrevName; + _daylightDisplayName = daylightAbbrevName ?? standardAbbrevName; _displayName = _standardDisplayName; - string uiCulture = CultureInfo.CurrentUICulture.Name.Length == 0 ? FallbackCultureName : CultureInfo.CurrentUICulture.Name; // ICU doesn't work nicely with Invariant - GetDisplayName(Interop.Globalization.TimeZoneDisplayNameType.Generic, uiCulture, ref _displayName); - GetDisplayName(Interop.Globalization.TimeZoneDisplayNameType.Standard, uiCulture, ref _standardDisplayName); - GetDisplayName(Interop.Globalization.TimeZoneDisplayNameType.DaylightSavings, uiCulture, ref _daylightDisplayName); + // Determine the culture to use + CultureInfo uiCulture = CultureInfo.CurrentUICulture; + if (uiCulture.Name.Length == 0) + uiCulture = CultureInfo.GetCultureInfo(FallbackCultureName); // ICU doesn't work nicely with InvariantCulture - if (_standardDisplayName == _displayName) - { - if (_baseUtcOffset >= TimeSpan.Zero) - _displayName = $"(UTC+{_baseUtcOffset:hh\\:mm}) {_standardDisplayName}"; - else - _displayName = $"(UTC-{_baseUtcOffset:hh\\:mm}) {_standardDisplayName}"; - } + // Attempt to populate the fields backing the StandardName, DaylightName, and DisplayName from globalization data. + GetDisplayName(_id, Interop.Globalization.TimeZoneDisplayNameType.Standard, uiCulture.Name, ref _standardDisplayName); + GetDisplayName(_id, Interop.Globalization.TimeZoneDisplayNameType.DaylightSavings, uiCulture.Name, ref _daylightDisplayName); + GetFullValueForDisplayNameField(_id, _baseUtcOffset, uiCulture, ref _displayName); // TZif supports seconds-level granularity with offsets but TimeZoneInfo only supports minutes since it aligns // with DateTimeOffset, SQL Server, and the W3C XML Specification @@ -114,6 +145,133 @@ private TimeZoneInfo(byte[] data, string id, bool dstDisabled) ValidateTimeZoneInfo(_id, _baseUtcOffset, _adjustmentRules, out _supportsDaylightSavingTime); } + // Helper function that builds the value backing the DisplayName field from gloablization data. + private static void GetFullValueForDisplayNameField(string timeZoneId, TimeSpan baseUtcOffset, CultureInfo uiCulture, ref string? displayName) + { + // There are a few diffent ways we might show the display name depending on the data. + // The algorithm used below should avoid duplicating the same words while still achieving the + // goal of providing a unique, discoverable, and intuitive name. + + // Get the base offset to prefix in front of the time zone. + // Only UTC and its aliases have "(UTC)", handled earlier. All other zones include an offset, even if it's zero. + string baseOffsetText = $"(UTC{(baseUtcOffset >= TimeSpan.Zero ? '+' : '-')}{baseUtcOffset:hh\\:mm})"; + + // Try to get the generic name for this time zone. + string? genericName = null; + GetDisplayName(timeZoneId, Interop.Globalization.TimeZoneDisplayNameType.Generic, uiCulture.Name, ref genericName); + + if (genericName == null) + { + // When we can't get a generic name, use the offset and the ID. + // It is not ideal, but at least it is non-ambiguous. + // (Note, UTC was handled already above.) + displayName = $"{baseOffsetText} {timeZoneId}"; + return; + } + + // Get the generic location name. + string? genericLocationName = null; + GetDisplayName(timeZoneId, Interop.Globalization.TimeZoneDisplayNameType.GenericLocation, uiCulture.Name, ref genericLocationName); + + // Some edge cases only apply when the offset is +00:00. + if (baseUtcOffset == TimeSpan.Zero) + { + // GMT and its aliases will just use the equivalent of "Greenwich Mean Time". + string? gmtLocationName = null; + GetDisplayName(GmtId, Interop.Globalization.TimeZoneDisplayNameType.GenericLocation, uiCulture.Name, ref gmtLocationName); + if (genericLocationName == gmtLocationName) + { + displayName = $"{baseOffsetText} {genericName}"; + return; + } + + // Other zones with a zero offset and the equivalent of "Greenwich Mean Time" should only use the location name. + // For example, prefer "Iceland Time" over "Greenwich Mean Time (Reykjavik)". + string? gmtGenericName = null; + GetDisplayName(GmtId, Interop.Globalization.TimeZoneDisplayNameType.Generic, uiCulture.Name, ref gmtGenericName); + if (genericName == gmtGenericName) + { + displayName = $"{baseOffsetText} {genericLocationName}"; + return; + } + } + + if (genericLocationName == genericName) + { + // When the location name is the same as the generic name, + // then it is generally good enough to show by itself. + + // *** Example (en-US) *** + // id = "America/Havana" + // baseOffsetText = "(UTC-05:00)" + // standardName = "Cuba Standard Time" + // genericName = "Cuba Time" + // genericLocationName = "Cuba Time" + // exemplarCityName = "Havana" + // displayName = "(UTC-05:00) Cuba Time" + + displayName = $"{baseOffsetText} {genericLocationName}"; + return; + } + + // Prefer location names in some special cases. + if (StringArrayContains(timeZoneId, s_ZonesThatUseLocationName, StringComparison.OrdinalIgnoreCase)) + { + displayName = $"{baseOffsetText} {genericLocationName}"; + return; + } + + // See if we should include the exemplar city name. + string exemplarCityName = GetExemplarCityName(timeZoneId, uiCulture.Name); + if (uiCulture.CompareInfo.IndexOf(genericName, exemplarCityName, CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace) >= 0 && genericLocationName != null) + { + // When an exemplar city is already part of the generic name, + // there's no need to repeat it again so just use the generic name. + + // *** Example (fr-FR) *** + // id = "Australia/Lord_Howe" + // baseOffsetText = "(UTC+10:30)" + // standardName = "heure normale de Lord Howe" + // genericName = "heure de Lord Howe" + // genericLocationName = "heure : Lord Howe" + // exemplarCityName = "Lord Howe" + // displayName = "(UTC+10:30) heure de Lord Howe" + + displayName = $"{baseOffsetText} {genericName}"; + } + else + { + // Finally, use the generic name and the exemplar city together. + // This provides an intuitive name and still disambiguates. + + // *** Example (en-US) *** + // id = "Europe/Rome" + // baseOffsetText = "(UTC+01:00)" + // standardName = "Central European Standard Time" + // genericName = "Central European Time" + // genericLocationName = "Italy Time" + // exemplarCityName = "Rome" + // displayName = "(UTC+01:00) Central European Time (Rome)" + + displayName = $"{baseOffsetText} {genericName} ({exemplarCityName})"; + } + } + + private static string GetExemplarCityName(string timeZoneId, string uiCultureName) + { + // First try to get the name through the localization data. + string? exemplarCityName = null; + GetDisplayName(timeZoneId, Interop.Globalization.TimeZoneDisplayNameType.ExemplarCity, uiCultureName, ref exemplarCityName); + if (!string.IsNullOrEmpty(exemplarCityName)) + return exemplarCityName; + + // Support for getting exemplar city names was added in ICU 51. + // We may have an older version. For example, in Helix we test on RHEL 7.5 which uses ICU 50.1.2. + // We'll fallback to using an English name generated from the time zone ID. + int i = timeZoneId.LastIndexOf('/'); + return timeZoneId.Substring(i + 1).Replace('_', ' '); + } + // The TransitionTime fields are not used when AdjustmentRule.NoDaylightTransitions == true. // However, there are some cases in the past where DST = true, and the daylight savings offset // now equals what the current BaseUtcOffset is. In that case, the AdjustmentRule.DaylightOffset @@ -988,7 +1146,7 @@ private static void TZif_GenerateAdjustmentRule(ref int index, TimeSpan timeZone baseUtcDelta, noDaylightTransitions: true); - if (!IsValidAdjustmentRuleOffest(timeZoneBaseUtcOffset, r)) + if (!IsValidAdjustmentRuleOffset(timeZoneBaseUtcOffset, r)) { NormalizeAdjustmentRuleOffset(timeZoneBaseUtcOffset, ref r); } @@ -1031,7 +1189,7 @@ private static void TZif_GenerateAdjustmentRule(ref int index, TimeSpan timeZone baseUtcDelta, noDaylightTransitions: true); - if (!IsValidAdjustmentRuleOffest(timeZoneBaseUtcOffset, r)) + if (!IsValidAdjustmentRuleOffset(timeZoneBaseUtcOffset, r)) { NormalizeAdjustmentRuleOffset(timeZoneBaseUtcOffset, ref r); } @@ -1068,7 +1226,7 @@ private static void TZif_GenerateAdjustmentRule(ref int index, TimeSpan timeZone noDaylightTransitions: true); } - if (!IsValidAdjustmentRuleOffest(timeZoneBaseUtcOffset, r)) + if (!IsValidAdjustmentRuleOffset(timeZoneBaseUtcOffset, r)) { NormalizeAdjustmentRuleOffset(timeZoneBaseUtcOffset, ref r); } @@ -1789,5 +1947,38 @@ private enum TZVersion : byte V3, // when adding more versions, ensure all the logic using TZVersion is still correct } + + // Helper function for string array search. (LINQ is not available here.) + private static bool StringArrayContains(string value, string[] source, StringComparison comparison) + { + foreach (string s in source) + { + if (string.Equals(s, value, comparison)) + { + return true; + } + } + + return false; + } + + // Helper function to get the standard display name for the UTC static time zone instance + private static string GetUtcStandardDisplayName() + { + // Don't bother looking up the name for invariant or English cultures + CultureInfo uiCulture = CultureInfo.CurrentUICulture; + if (GlobalizationMode.Invariant || uiCulture.Name.Length == 0 || uiCulture.TwoLetterISOLanguageName == "en") + return InvariantUtcStandardDisplayName; + + // Try to get a localized version of "Coordinated Universal Time" from the globalization data + string? standardDisplayName = null; + GetDisplayName(UtcId, Interop.Globalization.TimeZoneDisplayNameType.Standard, uiCulture.Name, ref standardDisplayName); + + // Final safety check. Don't allow null or abbreviations + if (standardDisplayName == null || standardDisplayName == "GMT" || standardDisplayName == "UTC") + standardDisplayName = InvariantUtcStandardDisplayName; + + return standardDisplayName; + } } } diff --git a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Win32.cs b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Win32.cs index 6223e66e018ae..8cc7ee8bb88f4 100644 --- a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Win32.cs +++ b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Win32.cs @@ -1000,5 +1000,43 @@ private static TimeZoneInfoResult TryGetTimeZoneFromLocalMachine(string id, out } } } + + // Helper function to get the standard display name for the UTC static time zone instance + private static string GetUtcStandardDisplayName() + { + // Don't bother looking up the name for invariant or English cultures + CultureInfo uiCulture = CultureInfo.CurrentUICulture; + if (uiCulture.Name.Length == 0 || uiCulture.TwoLetterISOLanguageName == "en") + return InvariantUtcStandardDisplayName; + + // Try to get a localized version of "Coordinated Universal Time" from the globalization data + string? standardDisplayName = null; + using (RegistryKey? key = Registry.LocalMachine.OpenSubKey(TimeZonesRegistryHive + "\\" + UtcId, writable: false)) + { + if (key != null) + { + // read the MUI_ registry key + string? standardNameMuiResource = key.GetValue(MuiStandardValue, string.Empty) as string; + + // try to load the string from the native resource DLL(s) + if (!string.IsNullOrEmpty(standardNameMuiResource)) + { + standardDisplayName = TryGetLocalizedNameByMuiNativeResource(standardNameMuiResource); + } + + // fallback to using the standard registry key + if (string.IsNullOrEmpty(standardDisplayName)) + { + standardDisplayName = key.GetValue(StandardValue, string.Empty) as string; + } + } + } + + // Final safety check. Don't allow null or abbreviations + if (standardDisplayName == null || standardDisplayName == "GMT" || standardDisplayName == "UTC") + standardDisplayName = InvariantUtcStandardDisplayName; + + return standardDisplayName; + } } } diff --git a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs index 771cef56a3a8c..90a425e4f672f 100644 --- a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs +++ b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs @@ -52,8 +52,9 @@ private enum TimeZoneInfoResult // constants for TimeZoneInfo.Local and TimeZoneInfo.Utc private const string UtcId = "UTC"; private const string LocalId = "Local"; + private const string InvariantUtcStandardDisplayName = "Coordinated Universal Time"; - private static readonly TimeZoneInfo s_utcTimeZone = CreateCustomTimeZone(UtcId, TimeSpan.Zero, "(UTC) Coordinated Universal Time", "Coordinated Universal Time"); + private static readonly TimeZoneInfo s_utcTimeZone = CreateUtcTimeZone(); private static CachedData s_cachedData = new CachedData(); @@ -1976,7 +1977,7 @@ private static void ValidateTimeZoneInfo(string id, TimeSpan baseUtcOffset, Adju throw new InvalidTimeZoneException(SR.Argument_AdjustmentRulesNoNulls); } - if (!IsValidAdjustmentRuleOffest(baseUtcOffset, current)) + if (!IsValidAdjustmentRuleOffset(baseUtcOffset, current)) { throw new InvalidTimeZoneException(SR.ArgumentOutOfRange_UtcOffsetAndDaylightDelta); } @@ -2009,10 +2010,18 @@ private static TimeSpan GetUtcOffset(TimeSpan baseUtcOffset, AdjustmentRule adju /// /// Helper function that performs adjustment rule validation /// - private static bool IsValidAdjustmentRuleOffest(TimeSpan baseUtcOffset, AdjustmentRule adjustmentRule) + private static bool IsValidAdjustmentRuleOffset(TimeSpan baseUtcOffset, AdjustmentRule adjustmentRule) { TimeSpan utcOffset = GetUtcOffset(baseUtcOffset, adjustmentRule); return !UtcOffsetOutOfRange(utcOffset); } + + // Helper function to create the static UTC time zone instance + private static TimeZoneInfo CreateUtcTimeZone() + { + string standardDisplayName = GetUtcStandardDisplayName(); + string displayName = $"(UTC) {standardDisplayName}"; + return CreateCustomTimeZone(UtcId, TimeSpan.Zero, displayName, standardDisplayName); + } } } diff --git a/src/libraries/System.Runtime.Serialization.Formatters/tests/BinaryFormatterTests.cs b/src/libraries/System.Runtime.Serialization.Formatters/tests/BinaryFormatterTests.cs index a7085a9dfc41d..155346f681de8 100644 --- a/src/libraries/System.Runtime.Serialization.Formatters/tests/BinaryFormatterTests.cs +++ b/src/libraries/System.Runtime.Serialization.Formatters/tests/BinaryFormatterTests.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Drawing; using System.Drawing.Imaging; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -96,6 +97,17 @@ private static void ValidateAndRoundtrip(object obj, TypeSerializableValue[] blo CheckObjectTypeIntegrity(customSerializableObj); } + // TimeZoneInfo objects have three properties (DisplayName, StandardName, DaylightName) + // that are localized. Since the blobs were generated from the invariant culture, they + // will have English strings embedded. Thus, we can only test them against English + // language cultures or the invariant culture. + if (obj is TimeZoneInfo && ( + CultureInfo.CurrentUICulture.TwoLetterISOLanguageName != "en" || + CultureInfo.CurrentUICulture.Name.Length != 0)) + { + return; + } + SanityCheckBlob(obj, blobs); // ReflectionTypeLoadException and LicenseException aren't deserializable from Desktop --> Core. diff --git a/src/libraries/System.Runtime/tests/System/TimeZoneInfoTests.cs b/src/libraries/System.Runtime/tests/System/TimeZoneInfoTests.cs index 35861477d2388..a78f76ec15360 100644 --- a/src/libraries/System.Runtime/tests/System/TimeZoneInfoTests.cs +++ b/src/libraries/System.Runtime/tests/System/TimeZoneInfoTests.cs @@ -81,45 +81,62 @@ public static void Names() Assert.NotNull(utc.ToString()); } - // Due to ICU size limitations, full daylight/standard names are not included. - // name abbreviations, if available, are used instead + // Due to ICU size limitations, full daylight/standard names are not included for the browser. + // Name abbreviations, if available, are used instead public static IEnumerable Platform_TimeZoneNamesTestData() { if (PlatformDetection.IsBrowser) return new TheoryData { - { TimeZoneInfo.FindSystemTimeZoneById(s_strPacific), "(UTC-08:00) PST", "PST", "PDT" }, - { TimeZoneInfo.FindSystemTimeZoneById(s_strSydney), "(UTC+10:00) AEST", "AEST", "AEDT" }, - { TimeZoneInfo.FindSystemTimeZoneById(s_strPerth), "(UTC+08:00) AWST", "AWST", "AWDT" }, - { TimeZoneInfo.FindSystemTimeZoneById(s_strIran), "(UTC+03:30) +0330", "+0330", "+0430" }, + { TimeZoneInfo.FindSystemTimeZoneById(s_strPacific), "(UTC-08:00) America/Los_Angeles", "PST", "PDT" }, + { TimeZoneInfo.FindSystemTimeZoneById(s_strSydney), "(UTC+10:00) Australia/Sydney", "AEST", "AEDT" }, + { TimeZoneInfo.FindSystemTimeZoneById(s_strPerth), "(UTC+08:00) Australia/Perth", "AWST", "AWDT" }, + { TimeZoneInfo.FindSystemTimeZoneById(s_strIran), "(UTC+03:30) Asia/Tehran", "+0330", "+0430" }, - { s_NewfoundlandTz, "(UTC-03:30) NST", "NST", "NDT" }, - { s_catamarcaTz, "(UTC-03:00) -03", "-03", "-02" } + { s_NewfoundlandTz, "(UTC-03:30) America/St_Johns", "NST", "NDT" }, + { s_catamarcaTz, "(UTC-03:00) America/Argentina/Catamarca", "-03", "-02" } + }; + else if (PlatformDetection.IsWindows) + return new TheoryData + { + { TimeZoneInfo.FindSystemTimeZoneById(s_strPacific), "(UTC-08:00) Pacific Time (US & Canada)", "Pacific Standard Time", "Pacific Daylight Time" }, + { TimeZoneInfo.FindSystemTimeZoneById(s_strSydney), "(UTC+10:00) Canberra, Melbourne, Sydney", "AUS Eastern Standard Time", "AUS Eastern Daylight Time" }, + { TimeZoneInfo.FindSystemTimeZoneById(s_strPerth), "(UTC+08:00) Perth", "W. Australia Standard Time", "W. Australia Daylight Time" }, + { TimeZoneInfo.FindSystemTimeZoneById(s_strIran), "(UTC+03:30) Tehran", "Iran Standard Time", "Iran Daylight Time" }, + + { s_NewfoundlandTz, "(UTC-03:30) Newfoundland", "Newfoundland Standard Time", "Newfoundland Daylight Time" }, + { s_catamarcaTz, "(UTC-03:00) City of Buenos Aires", "Argentina Standard Time", "Argentina Daylight Time" } }; else return new TheoryData { - { TimeZoneInfo.FindSystemTimeZoneById(s_strPacific), "(UTC-08:00) Pacific Standard Time", "Pacific Standard Time", "Pacific Daylight Time" }, - { TimeZoneInfo.FindSystemTimeZoneById(s_strSydney), "(UTC+10:00) Australian Eastern Standard Time", "Australian Eastern Standard Time", "Australian Eastern Daylight Time" }, - { TimeZoneInfo.FindSystemTimeZoneById(s_strPerth), "(UTC+08:00) Australian Western Standard Time", "Australian Western Standard Time", "Australian Western Daylight Time" }, - { TimeZoneInfo.FindSystemTimeZoneById(s_strIran), "(UTC+03:30) +0330", "+0330", "+0430" }, + { TimeZoneInfo.FindSystemTimeZoneById(s_strPacific), "(UTC-08:00) Pacific Time (Los Angeles)", "Pacific Standard Time", "Pacific Daylight Time" }, + { TimeZoneInfo.FindSystemTimeZoneById(s_strSydney), "(UTC+10:00) Eastern Australia Time (Sydney)", "Australian Eastern Standard Time", "Australian Eastern Daylight Time" }, + { TimeZoneInfo.FindSystemTimeZoneById(s_strPerth), "(UTC+08:00) Australian Western Standard Time (Perth)", "Australian Western Standard Time", "Australian Western Daylight Time" }, + { TimeZoneInfo.FindSystemTimeZoneById(s_strIran), "(UTC+03:30) Iran Time", "Iran Standard Time", "Iran Daylight Time" }, - { s_NewfoundlandTz, "(UTC-03:30) NST", "NST", "NDT" }, - { s_catamarcaTz, "(UTC-03:00) -03", "-03", "-02" } + { s_NewfoundlandTz, "(UTC-03:30) Newfoundland Time (St. John’s)", "Newfoundland Standard Time", "Newfoundland Daylight Time" }, + { s_catamarcaTz, "(UTC-03:00) Argentina Standard Time (Catamarca)", "Argentina Standard Time", "Argentina Summer Time" } }; } - [Theory] + // We test the existence of a specific English time zone name to avoid failures on non-English platforms. + [ConditionalTheory(nameof(IsEnglishUILanguage))] [MemberData(nameof(Platform_TimeZoneNamesTestData))] - [PlatformSpecific(TestPlatforms.AnyUnix)] public static void Platform_TimeZoneNames(TimeZoneInfo tzi, string displayName, string standardName, string daylightName) { - if (PlatformDetection.IsBrowser) + // Edge case - Optionally allow some characters to be absent in the display name. + const string chars = ".’"; + foreach (char c in chars) { - // Console.WriteLine($"DisplayName: {tzi.DisplayName}, StandardName: {tzi.StandardName}, DaylightName: {tzi.DaylightName}"); - Assert.Equal($"DisplayName: {tzi.DisplayName}, StandardName: {tzi.StandardName}, DaylightName: {tzi.DaylightName}", - $"DisplayName: {displayName}, StandardName: {standardName}, DaylightName: {daylightName}"); + if (displayName.Contains(c, StringComparison.Ordinal) && !tzi.DisplayName.Contains(c, StringComparison.Ordinal)) + { + displayName = displayName.Replace(c.ToString(), "", StringComparison.Ordinal); + } } + + Assert.Equal($"DisplayName: \"{displayName}\", StandardName: {standardName}\", DaylightName: {daylightName}\"", + $"DisplayName: \"{tzi.DisplayName}\", StandardName: {tzi.StandardName}\", DaylightName: {tzi.DaylightName}\""); } [Fact] @@ -160,8 +177,9 @@ public static void LibyaTimeZone() Assert.True(libyaLocalTime.Equals(expectResult), string.Format("Expected {0} and got {1}", expectResult, libyaLocalTime)); } - [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] - public static void TestYukunTZ() + [Fact] + [PlatformSpecific(TestPlatforms.Windows)] + public static void TestYukonTZ() { try { @@ -190,7 +208,7 @@ public static void TestYukunTZ() } catch (TimeZoneNotFoundException) { - // Some Windows versions don't carry the complete TZ data. Ignore the tests on such versiosn. + // Some Windows versions don't carry the complete TZ data. Ignore the tests on such versions. } } @@ -2288,6 +2306,75 @@ public static IEnumerable SystemTimeZonesTestData() { yield return new object[] { tz }; } + + // Include fixed offset IANA zones in the test data when they are available. + if (!PlatformDetection.IsWindows) + { + for (int i = -14; i <= 12; i++) + { + TimeZoneInfo tz = null; + + try + { + string id = $"Etc/GMT{i:+0;-0}"; + tz = TimeZoneInfo.FindSystemTimeZoneById(id); + } + catch (TimeZoneNotFoundException) + { + } + + if (tz != null) + { + yield return new object[] { tz }; + } + } + } + } + + private const string IanaAbbreviationPattern = @"^(?:[A-Z][A-Za-z]+|[+-]\d{2}|[+-]\d{4})$"; + private static readonly Regex s_IanaAbbreviationRegex = new Regex(IanaAbbreviationPattern); + + [Theory] + [MemberData(nameof(SystemTimeZonesTestData))] + [PlatformSpecific(TestPlatforms.AnyUnix)] + public static void TimeZoneDisplayNames_Unix(TimeZoneInfo timeZone) + { + if (timeZone.Id == TimeZoneInfo.Utc.Id || timeZone.StandardName == TimeZoneInfo.Utc.StandardName) + { + // UTC's display name is always the string "(UTC) " and the same text as the standard name. + Assert.True(timeZone.DisplayName == $"(UTC) {timeZone.StandardName}", + $"Id: \"{timeZone.Id}\", Expected DisplayName: \"(UTC) {timeZone.StandardName}\", Actual DisplayName: \"{timeZone.DisplayName}\""); + + // All aliases of UTC should have the same names as UTC itself + Assert.True(timeZone.DisplayName == TimeZoneInfo.Utc.DisplayName, + $"Id: \"{timeZone.Id}\", Expected DisplayName: \"{TimeZoneInfo.Utc.DisplayName}\", Actual DisplayName: \"{timeZone.DisplayName}\""); + Assert.True(timeZone.StandardName == TimeZoneInfo.Utc.StandardName, + $"Id: \"{timeZone.Id}\", Expected StandardName: \"{TimeZoneInfo.Utc.StandardName}\", Actual StandardName: \"{timeZone.StandardName}\""); + Assert.True(timeZone.DaylightName == TimeZoneInfo.Utc.DaylightName, + $"Id: \"{timeZone.Id}\", Expected DaylightName: \"{TimeZoneInfo.Utc.DaylightName}\", Actual DaylightName: \"{timeZone.DaylightName}\""); + } + else if (PlatformDetection.IsBrowser) + { + // Browser platform doesn't have full ICU names, but uses the IANA data instead. + + // The display name will be the offset plus the ID. + // The offset is checked separately in TimeZoneInfo_DisplayNameStartsWithOffset + Assert.True(timeZone.DisplayName.EndsWith(" " + timeZone.Id), + $"Id: \"{timeZone.Id}\", DisplayName should have ended with the ID, Actual DisplayName: \"{timeZone.DisplayName}\""); + + // Match any valid IANA time zone abbreviation, including numeric forms + Assert.True(s_IanaAbbreviationRegex.IsMatch(timeZone.StandardName), + $"Id: \"{timeZone.Id}\", StandardName should have matched the pattern @\"{IanaAbbreviationPattern}\", Actual StandardName: \"{timeZone.StandardName}\""); + Assert.True(s_IanaAbbreviationRegex.IsMatch(timeZone.DaylightName), + $"Id: \"{timeZone.Id}\", DaylightName should have matched the pattern @\"{IanaAbbreviationPattern}\", Actual DaylightName: \"{timeZone.DaylightName}\""); + } + else + { + // All we can really say generically here is that they aren't empty. + Assert.False(string.IsNullOrWhiteSpace(timeZone.DisplayName), $"Id: \"{timeZone.Id}\", DisplayName should not have been empty."); + Assert.False(string.IsNullOrWhiteSpace(timeZone.StandardName), $"Id: \"{timeZone.Id}\", StandardName should not have been empty."); + Assert.False(string.IsNullOrWhiteSpace(timeZone.DaylightName), $"Id: \"{timeZone.Id}\", DaylightName should not have been empty."); + } } [ActiveIssue("https://github.com/dotnet/runtime/issues/19794", TestPlatforms.AnyUnix)] @@ -2365,44 +2452,47 @@ public static void TimeZoneInfo_DaylightDeltaIsNoMoreThan12Hours() } } - [Fact] - public static void TimeZoneInfo_DisplayNameStartsWithOffset() + [Theory] + [MemberData(nameof(SystemTimeZonesTestData))] + public static void TimeZoneInfo_DisplayNameStartsWithOffset(TimeZoneInfo tzi) { - foreach (TimeZoneInfo tzi in TimeZoneInfo.GetSystemTimeZones()) + if (tzi.StandardName == TimeZoneInfo.Utc.StandardName) { - if (tzi.Id != "UTC") - { - Assert.False(string.IsNullOrWhiteSpace(tzi.StandardName)); - Assert.Matches(@"^\(UTC(\+|-)[0-9]{2}:[0-9]{2}\) \S.*", tzi.DisplayName); + // UTC and all of its aliases (Etc/UTC, and others) start with just "(UTC) " + Assert.StartsWith("(UTC) ", tzi.DisplayName); + } + else + { + Assert.False(string.IsNullOrWhiteSpace(tzi.StandardName)); + Assert.Matches(@"^\(UTC(\+|-)[0-9]{2}:[0-9]{2}\) \S.*", tzi.DisplayName); - // see https://github.com/dotnet/corefx/pull/33204#issuecomment-438782500 - if (PlatformDetection.IsNotWindowsNanoServer && !PlatformDetection.IsWindows7) + // see https://github.com/dotnet/corefx/pull/33204#issuecomment-438782500 + if (PlatformDetection.IsNotWindowsNanoServer && !PlatformDetection.IsWindows7) + { + string offset = Regex.Match(tzi.DisplayName, @"(-|)[0-9]{2}:[0-9]{2}").Value; + TimeSpan ts = TimeSpan.Parse(offset); + if (PlatformDetection.IsWindows && + tzi.BaseUtcOffset != ts && + (tzi.Id.Contains("Morocco") || tzi.Id.Contains("Volgograd"))) { - string offset = Regex.Match(tzi.DisplayName, @"(-|)[0-9]{2}:[0-9]{2}").Value; - TimeSpan ts = TimeSpan.Parse(offset); - if (PlatformDetection.IsWindows && - tzi.BaseUtcOffset != ts && - (tzi.Id.Contains("Morocco") || tzi.Id.Contains("Volgograd"))) + // Windows data can report display name with UTC+01:00 offset which is not matching the actual BaseUtcOffset. + // We special case this in the test to avoid the test failures like: + // 01:00 != 00:00:00, dn:(UTC+01:00) Casablanca, sn:Morocco Standard Time + // 04:00 != 03:00:00, dn:(UTC+04:00) Volgograd, sn:Volgograd Standard Time + if (tzi.Id.Contains("Morocco")) { - // Windows data can report display name with UTC+01:00 offset which is not matching the actual BaseUtcOffset. - // We special case this in the test to avoid the test failures like: - // 01:00 != 00:00:00, dn:(UTC+01:00) Casablanca, sn:Morocco Standard Time - // 04:00 != 03:00:00, dn:(UTC+04:00) Volgograd, sn:Volgograd Standard Time - if (tzi.Id.Contains("Morocco")) - { - Assert.True(tzi.BaseUtcOffset == new TimeSpan(0, 0, 0), $"{offset} != {tzi.BaseUtcOffset}, dn:{tzi.DisplayName}, sn:{tzi.StandardName}"); - } - else - { - // Volgograd, Russia - Assert.True(tzi.BaseUtcOffset == new TimeSpan(3, 0, 0), $"{offset} != {tzi.BaseUtcOffset}, dn:{tzi.DisplayName}, sn:{tzi.StandardName}"); - } + Assert.True(tzi.BaseUtcOffset == new TimeSpan(0, 0, 0), $"{offset} != {tzi.BaseUtcOffset}, dn:{tzi.DisplayName}, sn:{tzi.StandardName}"); } else { - Assert.True(tzi.BaseUtcOffset == ts || tzi.GetUtcOffset(DateTime.Now) == ts, $"{offset} != {tzi.BaseUtcOffset}, dn:{tzi.DisplayName}, sn:{tzi.StandardName}"); + // Volgograd, Russia + Assert.True(tzi.BaseUtcOffset == new TimeSpan(3, 0, 0), $"{offset} != {tzi.BaseUtcOffset}, dn:{tzi.DisplayName}, sn:{tzi.StandardName}"); } } + else + { + Assert.True(tzi.BaseUtcOffset == ts || tzi.GetUtcOffset(DateTime.Now) == ts, $"{offset} != {tzi.BaseUtcOffset}, dn:{tzi.DisplayName}, sn:{tzi.StandardName}"); + } } } } @@ -2483,7 +2573,8 @@ public static void TestTimeZoneIdBackwardCompatibility(string oldId, string curr TimeZoneInfo currenttz = TimeZoneInfo.FindSystemTimeZoneById(currentId); Assert.Equal(oldtz.StandardName, currenttz.StandardName); - Assert.Equal(oldtz.DisplayName, currenttz.DisplayName); + Assert.Equal(oldtz.DaylightName, currenttz.DaylightName); + // Note we cannot test the DisplayName, as it will contain the ID. } [Theory] @@ -2497,7 +2588,8 @@ public static void TestTimeZoneIdBackwardCompatibility(string oldId, string curr public static void ChangeLocalTimeZone(string id) { string originalTZ = Environment.GetEnvironmentVariable("TZ"); - try { + try + { TimeZoneInfo.ClearCachedData(); Environment.SetEnvironmentVariable("TZ", id); @@ -2507,13 +2599,16 @@ public static void ChangeLocalTimeZone(string id) Assert.Equal(tz.StandardName, localtz.StandardName); Assert.Equal(tz.DisplayName, localtz.DisplayName); } - finally { + finally + { TimeZoneInfo.ClearCachedData(); Environment.SetEnvironmentVariable("TZ", originalTZ); } } - private static bool IsEnglishUILanguageAndRemoteExecutorSupported => (CultureInfo.CurrentUICulture.Name == "en" || CultureInfo.CurrentUICulture.Name.StartsWith("en-", StringComparison.Ordinal)) && RemoteExecutor.IsSupported; + private static bool IsEnglishUILanguage => CultureInfo.CurrentUICulture.Name.Length == 0 || CultureInfo.CurrentUICulture.TwoLetterISOLanguageName == "en"; + + private static bool IsEnglishUILanguageAndRemoteExecutorSupported => IsEnglishUILanguage && RemoteExecutor.IsSupported; private static void VerifyConvertException(DateTimeOffset inputTime, string destinationTimeZoneId) where TException : Exception {