diff --git a/src/libraries/Common/src/Interop/Interop.TimeZoneInfo.cs b/src/libraries/Common/src/Interop/Interop.TimeZoneInfo.cs index 7dc5100a8b094e..0f0730dc166273 100644 --- a/src/libraries/Common/src/Interop/Interop.TimeZoneInfo.cs +++ b/src/libraries/Common/src/Interop/Interop.TimeZoneInfo.cs @@ -8,6 +8,9 @@ internal static partial class Interop { internal static partial class Globalization { + [LibraryImport(Libraries.GlobalizationNative, EntryPoint = "GlobalizationNative_GetCanonicalLocationTimeZoneIds")] + internal static partial int GetCanonicalLocationTimeZoneIds([Out] char[]? value, int valueLength); + [LibraryImport(Libraries.GlobalizationNative, EntryPoint = "GlobalizationNative_GetTimeZoneDisplayName", StringMarshalling = StringMarshalling.Utf16)] internal static unsafe partial ResultCode GetTimeZoneDisplayName( string localeName, diff --git a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Unix.Android.cs b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Unix.Android.cs index d280e410f87510..b06cf3fa836a7d 100644 --- a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Unix.Android.cs +++ b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Unix.Android.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.IO; using System.Runtime.InteropServices; using System.Text; @@ -211,6 +212,63 @@ private static string GetApexTimeDataRoot() return "/apex/com.android.tzdata"; } + private static string? GetVersionedTimeZoneDataDirectory(string apexTimeDataRoot) + { + // Android 15+ stores time zone data under versioned/. + // Use the newest format supported by the running OS; newer releases retain older formats for compatibility. + // https://android.googlesource.com/platform/system/timezone/+/0470df3d38d8e08932ebbe08b3d8ec9bbdcd403f/README.android + string? formatMajorVersion = + OperatingSystem.IsAndroidVersionAtLeast(36) ? "9" : + OperatingSystem.IsAndroidVersionAtLeast(35) ? "8" : + OperatingSystem.IsAndroidVersionAtLeast(34) ? "7" : + OperatingSystem.IsAndroidVersionAtLeast(33) ? "6" : + OperatingSystem.IsAndroidVersionAtLeast(31) ? "5" : + OperatingSystem.IsAndroidVersionAtLeast(30) ? "4" : + OperatingSystem.IsAndroidVersionAtLeast(29) ? "3" : + null; + + if (formatMajorVersion is null) + { + return null; + } + + string versionedRoot = Path.Combine(apexTimeDataRoot, "etc/tz/versioned"); + string preferredDirectory = Path.Combine(versionedRoot, formatMajorVersion); + if (File.Exists(Path.Combine(preferredDirectory, TimeZoneFileName))) + { + return preferredDirectory; + } + + // Compatibility versions are eventually pruned. If the preferred version is gone, + // use the newest available data rather than falling back to the non-updatable system copy. + string? latestDirectory = null; + int latestVersion = -1; + try + { + foreach (string directory in Directory.EnumerateDirectories(versionedRoot)) + { + string directoryName = Path.GetFileName(directory); + if (int.TryParse(directoryName, NumberStyles.None, CultureInfo.InvariantCulture, out int version) && + version > latestVersion && + File.Exists(Path.Combine(directory, TimeZoneFileName))) + { + latestDirectory = directory; + latestVersion = version; + } + } + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + + return latestDirectory; + } + private static string GetApexRuntimeRoot() { string? ret = Environment.GetEnvironmentVariable("ANDROID_RUNTIME_ROOT"); @@ -227,12 +285,19 @@ public AndroidTzData() // On Android, time zone data is found in tzdata // Based on https://github.com/mono/mono/blob/main/mcs/class/corlib/System/TimeZoneInfo.Android.cs // Also follows the locations found at the bottom of https://github.com/aosp-mirror/platform_bionic/blob/master/libc/tzcode/bionic.cpp - ReadOnlySpan tzFileDirList = [ GetApexTimeDataRoot() + "/etc/tz/", // Android 10+, TimeData module where the updates land - GetApexRuntimeRoot() + "/etc/tz/", // Android 10+, Fallback location if the above isn't found or corrupted - Environment.GetEnvironmentVariable("ANDROID_DATA") + "/misc/zoneinfo/", - Environment.GetEnvironmentVariable("ANDROID_ROOT") + DefaultTimeZoneDirectory ]; - foreach (var tzFileDir in tzFileDirList) + string apexTimeDataRoot = GetApexTimeDataRoot(); + ReadOnlySpan tzFileDirList = [ GetVersionedTimeZoneDataDirectory(apexTimeDataRoot), // Android 15+, versioned TimeData module + apexTimeDataRoot + "/etc/tz/", // Android 10+, unversioned TimeData module + GetApexRuntimeRoot() + "/etc/tz/", // Android 10+, fallback location if the above isn't found or corrupted + Environment.GetEnvironmentVariable("ANDROID_DATA") + "/misc/zoneinfo/", + Environment.GetEnvironmentVariable("ANDROID_ROOT") + DefaultTimeZoneDirectory ]; + foreach (string? tzFileDir in tzFileDirList) { + if (string.IsNullOrEmpty(tzFileDir)) + { + continue; + } + string tzFilePath = Path.Combine(tzFileDir, TimeZoneFileName); if (LoadData(tzFileDir, tzFilePath)) { @@ -272,7 +337,9 @@ public AndroidTzData() { string tzLookupFilePath = Path.Combine(tzFileDir, "tzlookup.xml"); if (!File.Exists(tzLookupFilePath)) - return null; + { + return GetCanonicalLocationTimeZoneIds(); + } HashSet? tzLookupIDs = null; try @@ -300,12 +367,52 @@ public AndroidTzData() } } } - catch + catch (IOException) + { + return GetCanonicalLocationTimeZoneIds(); + } + catch (UnauthorizedAccessException) + { + return GetCanonicalLocationTimeZoneIds(); + } + + return tzLookupIDs ?? GetCanonicalLocationTimeZoneIds(); + } + + private static HashSet? GetCanonicalLocationTimeZoneIds() + { + if (GlobalizationMode.Invariant) + { + return null; + } + + int bufferLength = Interop.Globalization.GetCanonicalLocationTimeZoneIds(null, 0); + if (bufferLength <= 0) { return null; } - return tzLookupIDs; + char[] buffer = new char[bufferLength]; + if (Interop.Globalization.GetCanonicalLocationTimeZoneIds(buffer, bufferLength) != bufferLength) + { + return null; + } + + HashSet ids = new HashSet(); + int index = 0; + while (index < bufferLength) + { + int idLength = buffer[index++]; + if (idLength == 0 || idLength > bufferLength - index) + { + return null; + } + + ids.Add(new string(buffer, index, idLength)); + index += idLength; + } + + return ids; } [MemberNotNullWhen(true, nameof(_ids))] diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeZoneInfoTests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeZoneInfoTests.cs index 3c9364e9f6b2b5..6132d377c3e03b 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeZoneInfoTests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeZoneInfoTests.cs @@ -3156,14 +3156,8 @@ public static void FijiTimeZoneTest() } [ConditionalFact] - [ActiveIssue("https://github.com/dotnet/runtime/issues/117731", TestPlatforms.Android)] public static void NoBackwardTimeZones() { - if (OperatingSystem.IsAndroid() && !OperatingSystem.IsAndroidVersionAtLeast(26)) - { - throw new SkipTestException("This test won't work on API level < 26"); - } - // Clear cached data to always ensure predictable results TimeZoneInfo.ClearCachedData(); if (SupportLegacyTimeZoneNames) @@ -3197,7 +3191,6 @@ public static void NoBackwardTimeZones() } [Fact] - [ActiveIssue("https://github.com/dotnet/runtime/issues/90269", TestPlatforms.Android)] public static void TestGetSystemTimeZones() { TimeZoneInfo.ClearCachedData(); // Start clean diff --git a/src/native/libs/System.Globalization.Native/entrypoints.c b/src/native/libs/System.Globalization.Native/entrypoints.c index 14a64d3f2b988a..21e1ed0b718594 100644 --- a/src/native/libs/System.Globalization.Native/entrypoints.c +++ b/src/native/libs/System.Globalization.Native/entrypoints.c @@ -53,6 +53,7 @@ static const Entry s_globalizationNative[] = DllImportEntry(GlobalizationNative_NormalizeString) DllImportEntry(GlobalizationNative_StartsWith) #ifndef __wasm__ + DllImportEntry(GlobalizationNative_GetCanonicalLocationTimeZoneIds) DllImportEntry(GlobalizationNative_GetTimeZoneDisplayName) DllImportEntry(GlobalizationNative_IanaIdToWindowsId) DllImportEntry(GlobalizationNative_WindowsIdToIanaId) diff --git a/src/native/libs/System.Globalization.Native/pal_placeholders.c b/src/native/libs/System.Globalization.Native/pal_placeholders.c index 413e2d311a29d8..9517e894f9c1c4 100644 --- a/src/native/libs/System.Globalization.Native/pal_placeholders.c +++ b/src/native/libs/System.Globalization.Native/pal_placeholders.c @@ -248,6 +248,13 @@ int32_t GlobalizationNative_NormalizeString( } // Placeholder for time zone data +int32_t GlobalizationNative_GetCanonicalLocationTimeZoneIds( + UChar* value, int32_t valueLength) +{ + assert_msg(false, "Not supported on this platform", 0); + return 0; +} + int32_t GlobalizationNative_WindowsIdToIanaId( const UChar* windowsId, const char* region, UChar* ianaId, int32_t ianaIdLength) { diff --git a/src/native/libs/System.Globalization.Native/pal_timeZoneInfo.c b/src/native/libs/System.Globalization.Native/pal_timeZoneInfo.c index e3da5a38b4e226..185d0ecaf83d57 100644 --- a/src/native/libs/System.Globalization.Native/pal_timeZoneInfo.c +++ b/src/native/libs/System.Globalization.Native/pal_timeZoneInfo.c @@ -53,6 +53,66 @@ int32_t GlobalizationNative_IanaIdToWindowsId(const UChar* ianaId, UChar* window return 0; } +/* +Get canonical location time zone IDs from ICU. +If value is null, returns the length needed to store the IDs. +Otherwise, stores each ID prefixed by its length and returns the number of UChars written. +Returns a negative number on failure. +*/ +int32_t GlobalizationNative_GetCanonicalLocationTimeZoneIds(UChar* value, int32_t valueLength) +{ + if (valueLength < 0) + { + return -1; + } + + UErrorCode status = U_ZERO_ERROR; + UEnumeration* timeZoneIds = ucal_openTimeZoneIDEnumeration(UCAL_ZONE_TYPE_CANONICAL_LOCATION, NULL, NULL, &status); + if (U_FAILURE(status) || timeZoneIds == NULL) + { + if (timeZoneIds != NULL) + { + uenum_close(timeZoneIds); + } + + return -1; + } + + int32_t totalLength = 0; + int32_t index = 0; + int32_t timeZoneIdLength; + const char* timeZoneId; + + while ((timeZoneId = uenum_next(timeZoneIds, &timeZoneIdLength, &status)) != NULL) + { + if (U_FAILURE(status) || + timeZoneIdLength <= 0 || + timeZoneIdLength > UINT16_MAX || + totalLength > INT32_MAX - timeZoneIdLength - 1) + { + uenum_close(timeZoneIds); + return -2; + } + + totalLength += timeZoneIdLength + 1; + if (value != NULL) + { + if (totalLength > valueLength) + { + uenum_close(timeZoneIds); + return -3; + } + + value[index++] = (UChar)timeZoneIdLength; + u_charsToUChars(timeZoneId, value + index, timeZoneIdLength); + index += timeZoneIdLength; + } + } + + uenum_close(timeZoneIds); + return U_SUCCESS(status) ? totalLength : -2; +} + /* Private function to get the standard and daylight names from the ICU Calendar API. */ diff --git a/src/native/libs/System.Globalization.Native/pal_timeZoneInfo.h b/src/native/libs/System.Globalization.Native/pal_timeZoneInfo.h index bb32c41d43db9d..d5696d74c78266 100644 --- a/src/native/libs/System.Globalization.Native/pal_timeZoneInfo.h +++ b/src/native/libs/System.Globalization.Native/pal_timeZoneInfo.h @@ -20,6 +20,7 @@ typedef enum TimeZoneDisplayName_ExemplarCity = 4, TimeZoneDisplayName_TimeZoneName = 5, } TimeZoneDisplayNameType; +PALEXPORT int32_t GlobalizationNative_GetCanonicalLocationTimeZoneIds(UChar* value, int32_t valueLength); PALEXPORT int32_t GlobalizationNative_WindowsIdToIanaId(const UChar* windowsId, const char* region, 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);