Determine Turkish casing from the locale name instead of collation - #132002

Open
MsfPablo wants to merge 3 commits into
dotnet:mainfrom
MsfPablo:turkish-casing-106560
Open

Determine Turkish casing from the locale name instead of collation#132002
MsfPablo wants to merge 3 commits into
dotnet:mainfrom
MsfPablo:turkish-casing-106560

Conversation

@MsfPablo

Copy link
Copy Markdown

Fixes#106560

Problem

On Android, "i".ToUpper(new CultureInfo("tr-TR")) returns I (U+0049) instead of İ (U+0130), and "I".ToLower(...) returns i instead of ı. The same code is correct on Windows/Linux/macOS.

Cause

TextInfo.NeedsTurkishCasing does not look at the locale at all — it infers the need for Turkish casing by probing the collation tailoring:

returnCultureInfo.GetCultureInfo(localeName).CompareInfo.Compare("ı","I",CompareOptions.IgnoreCase)==0;

Android uses the system ICU, which does not carry the tr/az collation tailoring that makes U+0131 compare equal to I under IgnoreCase (the same underlying gap tracked by #60568). The probe therefore returns false for tr-TR, IcuChangeCase takes the ChangeCase path instead of ChangeCaseTurkish, and the dotted/dotless i mapping is silently lost. GlobalizationNative_ChangeCaseTurkish itself is fine and is already built for Android — it is simply never called.

Fix

Decide from the locale's language subtag instead, which is what ICU itself does (ustrcase_getCaseLocale selects UCASE_LOC_TURKISH for languages tr and az). This removes the dependency on collation data and makes the behavior identical on every ICU platform.

This should be behavior-preserving elsewhere: tr and az are exactly the languages whose collation tailoring made the old probe return true, and the root collation does not equate U+0131 with I. The existing en-US-POSIX regression case — which exists precisely because the old check was collation-based — still passes, since its language subtag is en.

Tests

The Turkish ToUpper/ToLower cases in TextInfoTests were skipped on Android/LinuxBionic with the comment "Android has its own ICU, which doesn't work well with tr". Those guards are removed so the cases now run on those platforms and cover this fix. They exercise tr, tr-TR, az and az-Latn-AZ.

Verification

I want to be upfront: I have not built or run this locally. Building dotnet/runtime for Android and running the globalization suite on a device/emulator was not something I could do in my environment, so I am relying on CI (including the Android legs) to validate it. The change is small and self-contained, and the re-enabled test cases are the intended proof. Happy to iterate if CI shows anything unexpected — in particular if some locale I have not considered relied on the old collation-derived answer.

cc @matouskozak@tarekgh@ilonatommy

@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Aug 7, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'arch-android': @vitek-karas, @simonrozsival, @steveisok, @akoeplinger
See info in area-owners.md if you want to be subscribed.

@tarekghtarekgh added this to the 11.0.0 milestone Aug 7, 2026
@tarekgh

Copy link
Copy Markdown
Member

/azp run runtime-extra-platforms

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@matouskozak

matouskozak commented Aug 10, 2026

Copy link
Copy Markdown
Member

Thank you @MsfPablo for looking into this. Not sure if you're able to acces the CI results at: https://dev.azure.com/dnceng-public/public/_build/results?buildId=1544675&view=logs&jobId=c9e87f9f-b882-5f07-e8c2-f8cdb4f422a2&j=c9e87f9f-b882-5f07-e8c2-f8cdb4f422a2&t=f555a9c0-ef97-5d93-0768-acf0622a0bcb

If not, here is the extract of the failing tests:

 08-07 14:55:39.765 8433 8449 I DOTNET : System.Globalization.Tests.OrdinalCasingTests 0.2236177000000000111206515148 ms
08-07 14:55:39.775 8433 8449 I DOTNET : Failed tests:
08-07 14:55:39.776 8433 8449 I DOTNET : 1) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "I", expected: "ı") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "ı"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "i"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0052859999999999999001354389
08-07 14:55:39.776 8433 8449 I DOTNET : 2) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "HI!", expected: "hı!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0001764000000000000051465776
08-07 14:55:39.776 8433 8449 I DOTNET : 3) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "HI\n\0Hİ\t!", expected: "hı\n\0hi\t!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı\n\0hi\t!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi\n\0hi\t!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0001054000000000000005244069
08-07 14:55:39.776 8433 8449 I DOTNET : 4) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "I", expected: "ı") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "ı"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "i"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0020343000000000001706301767
08-07 14:55:39.776 8433 8449 I DOTNET : 5) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "HI!", expected: "hı!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000762999999999999979237095
08-07 14:55:39.777 8433 8449 I DOTNET : 6) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "HI\n\0Hİ\t!", expected: "hı\n\0hi\t!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "hı\n\0hi\t!"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "hi\n\0hi\t!"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000733999999999999951962038
08-07 14:55:39.777 8433 8449 I DOTNET : 7) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToUpper Test name: System.Globalization.Tests.TextInfoMiscTests.ToUpper(name: "tr", str: "i", expected: "İ") Test case: System.Globalization.Tests.TextInfoMiscTests.ToUpper
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "İ"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "I"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToUpper(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0001252000000000000074634743
08-07 14:55:39.777 8433 8449 I DOTNET : 8) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToUpper Test name: System.Globalization.Tests.TextInfoMiscTests.ToUpper(name: "tr-TR", str: "i", expected: "İ") Test case: System.Globalization.Tests.TextInfoMiscTests.ToUpper
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "İ"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "I"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToUpper(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000765000000000000027850985

@MsfPablo

Copy link
Copy Markdown
Author

@dotnet-policy-service agree


Thanks @matouskozak for the CI extract — I can see the same 8 failures land on Android, and the symptoms (Expected: ı / Actual: i, and ditto for U+0130 / U+0049) are exactly the bug the probe was supposed to flush out. The patch's NeedsTurkishCasing should now return true for tr / tr-TR / az/az-Latn-AZ and route IcuChangeCase through GlobalizationNative_ChangeCaseTurkish, which is unconditionally compiled in pal_casing.c (no __ANDROID__ guard around it). The native Turkish mapping itself is correct (U+0049 → U+0131 lower, U+0069 → U+0130 upper).

A few things I want to nail down before I push a follow-up commit, ideally with @tarekgh's CI results replicated locally:

  1. Where the path actually breaks. I want to check whether the failing tests reach IcuChangeCase at all — IsAsciiCasingSameAsInvariant for tr-TR is itself populated via a CompareInfo.Compare probe on line 582 of TextInfo.cs, which has the same Android-collation gap. If that probe ends up True on Android, ChangeCaseCommon takes the ASCII fast path and never calls IcuChangeCase. That's plausible and would explain the actuals — Ascii.ToLower('I') is hardcoded to U+0069.
  2. Build artefacts. Could you confirm that the Android job re-pulled/recompiled System.Private.CoreLib and System.Globalization.Native for this PR? The probe change is in C# but the dispatch is in the native PAL, and a stale AOT snapshot on the device would explain the symptoms exactly.
  3. LinuxBionic. Same tests are skipped on LinuxBionic in the old code with the same comment. Is LinuxBionic also failing, or only Android? If only Android, the native PAL is likely fine and the issue is in the managed dispatch on that platform specifically.

I'll re-examine the IsAsciiCasingSameAsInvariant path tonight and push a follow-up commit that either also derives from the locale name (consistent with the NeedsTurkishCasing decision) or short-circuits tr/az to False directly. If point 2 turns out to be the cause, that's a no-op fix and I'll say so.

Sorry for the CI thrash — I should have at least traced the whole ChangeCaseCommon path before opening, not just IcuChangeCase.

TextInfo.NeedsTurkishCasing detected the Turkish dotted/dotless i casing
rules by probing whether the culture collates U+0131 equal to 'I' under
IgnoreCase. On Android the system ICU does not provide the collation data
that probe depends on, so the check returned false for tr-TR and casing
silently fell back to the non-Turkish path, making i.ToUpper() yield
'I' instead of U+0130.
Fixesdotnet#106560
These ToLower/ToUpper cases were skipped on Android and LinuxBionic
because the collation-based Turkish detection did not work there. The
casing path no longer depends on collation data, so they can run.
The previous commit routed IcuChangeCase through GlobalizationNative_*
ChangeCaseTurkish for tr/az locales by deriving the language from the
locale name. The follow-up CI run on Android still failed with the same
8 tests returning U+0069/U+0049 instead of U+0131/U+0130, which means
IcuChangeCase was never reached at all on that platform.
The culprit is PopulateIsAsciiCasingSameAsInvariant in TextInfo.cs: the
same CompareInfo.Compare probe that NeedsTurkishCasing used to depend on
is also used here to detect whether ASCII casing matches the invariant
culture. On Android, where the system ICU's collation data is
incomplete, the probe incorrectly returns True for tr-TR, so
ChangeCaseCommon's ASCII fast path (line 262 / 321) calls
Ascii.ToLower/ToUpper, which hardcodes the U+0049 -> U+0069 mapping and
never invokes ChangeCaseCore. That matches the failure symptom exactly.
Mirror the locale-name derivation already used by NeedsTurkishCasing:
short-circuit tr/az to False directly in the populator, and only fall
through to the CompareInfo probe for other cultures. Also drop the test
guards for Android and LinuxBionic (already cherry-picked as a separate
commit), so the regression is now exercised in CI on all platforms.
@MsfPablo
MsfPabloforce-pushed the turkish-casing-106560 branch from 8979933 to 7a2a403CompareAugust 11, 2026 22:02
@MsfPablo

Copy link
Copy Markdown
Author

Confirmed the hypothesis from point (1) in my last comment — the failing tests never reached IcuChangeCase on Android. The root cause is a separate, second location that also depends on a CompareInfo.Compare probe: PopulateIsAsciiCasingSameAsInvariant in src/libraries/System.Private.CoreLib/src/System/Globalization/TextInfo.cs.

The mechanism. Both overloads of ChangeCaseCommon<TConversion> start with:

if (instance == null || instance.IsAsciiCasingSameAsInvariant)
{
Ascii.ToUpper(source, destination, out charsConsumed); // or ToLower
return; // on success
}

The IsAsciiCasingSameAsInvariant probe runs CompareInfo.Compare("a-z", "A-Z", IgnoreCase) == 0 against the locale's collation. On Android, where the system ICU's collation data is incomplete (the same gap NeedsTurkishCasing was probing around), that comparison incorrectly returns True for tr-TR. The ASCII fast path then takes over and calls Ascii.ToLower('I'), which is hardcoded to U+0069. ChangeCaseCore and therefore IcuChangeCase are never reached, which is why the original NeedsTurkishCasing fix looked correct but had no effect on the test actuals.

The fix (commit 7a2a403) mirrors the locale-name derivation already used by NeedsTurkishCasing: short-circuit tr/az to False in the populator before falling through to the unreliable CompareInfo.Compare probe. The two paths now agree by construction, and either fix alone would still leave the bug present.

Also pushed (6d3b84d): drop the IsAndroid/IsLinuxBionic test guards on the affected ToLower/ToUpper rows so this regression is exercised in CI on every platform going forward.

Sorry it took a follow-up commit rather than getting it right the first time — I should have traced the whole ChangeCaseCommon path before opening, not just IcuChangeCase.

@matouskozak

Copy link
Copy Markdown
Member

@MsfPablo were you able to verify the fix locally? We run thousands of tests on Android CI and as such iterating on a solution using just the CI is inefficient. We have instructions on how to build runtime for Android in https://github.com/dotnet/runtime/blob/main/docs/workflow/building/coreclr/android.md.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.Globalizationcommunity-contributionIndicates that the PR has been added by a community memberos-android

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android - With set Turkish locale "i".ToUpper() is giving 'I" instead of 'İ'

4 participants

@MsfPablo@tarekgh@matouskozak@ilonatommy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Determine Turkish casing from the locale name instead of collation - #132002

Open
MsfPablo wants to merge 3 commits into
dotnet:mainfrom
MsfPablo:turkish-casing-106560
Open

Determine Turkish casing from the locale name instead of collation#132002
MsfPablo wants to merge 3 commits into
dotnet:mainfrom
MsfPablo:turkish-casing-106560

Conversation

@MsfPablo

Copy link
Copy Markdown

Fixes#106560

Problem

On Android, "i".ToUpper(new CultureInfo("tr-TR")) returns I (U+0049) instead of İ (U+0130), and "I".ToLower(...) returns i instead of ı. The same code is correct on Windows/Linux/macOS.

Cause

TextInfo.NeedsTurkishCasing does not look at the locale at all — it infers the need for Turkish casing by probing the collation tailoring:

returnCultureInfo.GetCultureInfo(localeName).CompareInfo.Compare("ı","I",CompareOptions.IgnoreCase)==0;

Android uses the system ICU, which does not carry the tr/az collation tailoring that makes U+0131 compare equal to I under IgnoreCase (the same underlying gap tracked by #60568). The probe therefore returns false for tr-TR, IcuChangeCase takes the ChangeCase path instead of ChangeCaseTurkish, and the dotted/dotless i mapping is silently lost. GlobalizationNative_ChangeCaseTurkish itself is fine and is already built for Android — it is simply never called.

Fix

Decide from the locale's language subtag instead, which is what ICU itself does (ustrcase_getCaseLocale selects UCASE_LOC_TURKISH for languages tr and az). This removes the dependency on collation data and makes the behavior identical on every ICU platform.

This should be behavior-preserving elsewhere: tr and az are exactly the languages whose collation tailoring made the old probe return true, and the root collation does not equate U+0131 with I. The existing en-US-POSIX regression case — which exists precisely because the old check was collation-based — still passes, since its language subtag is en.

Tests

The Turkish ToUpper/ToLower cases in TextInfoTests were skipped on Android/LinuxBionic with the comment "Android has its own ICU, which doesn't work well with tr". Those guards are removed so the cases now run on those platforms and cover this fix. They exercise tr, tr-TR, az and az-Latn-AZ.

Verification

I want to be upfront: I have not built or run this locally. Building dotnet/runtime for Android and running the globalization suite on a device/emulator was not something I could do in my environment, so I am relying on CI (including the Android legs) to validate it. The change is small and self-contained, and the re-enabled test cases are the intended proof. Happy to iterate if CI shows anything unexpected — in particular if some locale I have not considered relied on the old collation-derived answer.

cc @matouskozak@tarekgh@ilonatommy

@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Aug 7, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'arch-android': @vitek-karas, @simonrozsival, @steveisok, @akoeplinger
See info in area-owners.md if you want to be subscribed.

@tarekghtarekgh added this to the 11.0.0 milestone Aug 7, 2026
@tarekgh

Copy link
Copy Markdown
Member

/azp run runtime-extra-platforms

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@matouskozak

matouskozak commented Aug 10, 2026

Copy link
Copy Markdown
Member

Thank you @MsfPablo for looking into this. Not sure if you're able to acces the CI results at: https://dev.azure.com/dnceng-public/public/_build/results?buildId=1544675&view=logs&jobId=c9e87f9f-b882-5f07-e8c2-f8cdb4f422a2&j=c9e87f9f-b882-5f07-e8c2-f8cdb4f422a2&t=f555a9c0-ef97-5d93-0768-acf0622a0bcb

If not, here is the extract of the failing tests:

 08-07 14:55:39.765 8433 8449 I DOTNET : System.Globalization.Tests.OrdinalCasingTests 0.2236177000000000111206515148 ms
08-07 14:55:39.775 8433 8449 I DOTNET : Failed tests:
08-07 14:55:39.776 8433 8449 I DOTNET : 1) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "I", expected: "ı") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "ı"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "i"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0052859999999999999001354389
08-07 14:55:39.776 8433 8449 I DOTNET : 2) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "HI!", expected: "hı!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0001764000000000000051465776
08-07 14:55:39.776 8433 8449 I DOTNET : 3) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "HI\n\0Hİ\t!", expected: "hı\n\0hi\t!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı\n\0hi\t!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi\n\0hi\t!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0001054000000000000005244069
08-07 14:55:39.776 8433 8449 I DOTNET : 4) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "I", expected: "ı") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "ı"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "i"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0020343000000000001706301767
08-07 14:55:39.776 8433 8449 I DOTNET : 5) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "HI!", expected: "hı!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000762999999999999979237095
08-07 14:55:39.777 8433 8449 I DOTNET : 6) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "HI\n\0Hİ\t!", expected: "hı\n\0hi\t!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "hı\n\0hi\t!"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "hi\n\0hi\t!"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000733999999999999951962038
08-07 14:55:39.777 8433 8449 I DOTNET : 7) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToUpper Test name: System.Globalization.Tests.TextInfoMiscTests.ToUpper(name: "tr", str: "i", expected: "İ") Test case: System.Globalization.Tests.TextInfoMiscTests.ToUpper
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "İ"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "I"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToUpper(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0001252000000000000074634743
08-07 14:55:39.777 8433 8449 I DOTNET : 8) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToUpper Test name: System.Globalization.Tests.TextInfoMiscTests.ToUpper(name: "tr-TR", str: "i", expected: "İ") Test case: System.Globalization.Tests.TextInfoMiscTests.ToUpper
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "İ"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "I"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToUpper(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000765000000000000027850985

@MsfPablo

Copy link
Copy Markdown
Author

@dotnet-policy-service agree


Thanks @matouskozak for the CI extract — I can see the same 8 failures land on Android, and the symptoms (Expected: ı / Actual: i, and ditto for U+0130 / U+0049) are exactly the bug the probe was supposed to flush out. The patch's NeedsTurkishCasing should now return true for tr / tr-TR / az/az-Latn-AZ and route IcuChangeCase through GlobalizationNative_ChangeCaseTurkish, which is unconditionally compiled in pal_casing.c (no __ANDROID__ guard around it). The native Turkish mapping itself is correct (U+0049 → U+0131 lower, U+0069 → U+0130 upper).

A few things I want to nail down before I push a follow-up commit, ideally with @tarekgh's CI results replicated locally:

  1. Where the path actually breaks. I want to check whether the failing tests reach IcuChangeCase at all — IsAsciiCasingSameAsInvariant for tr-TR is itself populated via a CompareInfo.Compare probe on line 582 of TextInfo.cs, which has the same Android-collation gap. If that probe ends up True on Android, ChangeCaseCommon takes the ASCII fast path and never calls IcuChangeCase. That's plausible and would explain the actuals — Ascii.ToLower('I') is hardcoded to U+0069.
  2. Build artefacts. Could you confirm that the Android job re-pulled/recompiled System.Private.CoreLib and System.Globalization.Native for this PR? The probe change is in C# but the dispatch is in the native PAL, and a stale AOT snapshot on the device would explain the symptoms exactly.
  3. LinuxBionic. Same tests are skipped on LinuxBionic in the old code with the same comment. Is LinuxBionic also failing, or only Android? If only Android, the native PAL is likely fine and the issue is in the managed dispatch on that platform specifically.

I'll re-examine the IsAsciiCasingSameAsInvariant path tonight and push a follow-up commit that either also derives from the locale name (consistent with the NeedsTurkishCasing decision) or short-circuits tr/az to False directly. If point 2 turns out to be the cause, that's a no-op fix and I'll say so.

Sorry for the CI thrash — I should have at least traced the whole ChangeCaseCommon path before opening, not just IcuChangeCase.

TextInfo.NeedsTurkishCasing detected the Turkish dotted/dotless i casing
rules by probing whether the culture collates U+0131 equal to 'I' under
IgnoreCase. On Android the system ICU does not provide the collation data
that probe depends on, so the check returned false for tr-TR and casing
silently fell back to the non-Turkish path, making i.ToUpper() yield
'I' instead of U+0130.
Fixesdotnet#106560
These ToLower/ToUpper cases were skipped on Android and LinuxBionic
because the collation-based Turkish detection did not work there. The
casing path no longer depends on collation data, so they can run.
The previous commit routed IcuChangeCase through GlobalizationNative_*
ChangeCaseTurkish for tr/az locales by deriving the language from the
locale name. The follow-up CI run on Android still failed with the same
8 tests returning U+0069/U+0049 instead of U+0131/U+0130, which means
IcuChangeCase was never reached at all on that platform.
The culprit is PopulateIsAsciiCasingSameAsInvariant in TextInfo.cs: the
same CompareInfo.Compare probe that NeedsTurkishCasing used to depend on
is also used here to detect whether ASCII casing matches the invariant
culture. On Android, where the system ICU's collation data is
incomplete, the probe incorrectly returns True for tr-TR, so
ChangeCaseCommon's ASCII fast path (line 262 / 321) calls
Ascii.ToLower/ToUpper, which hardcodes the U+0049 -> U+0069 mapping and
never invokes ChangeCaseCore. That matches the failure symptom exactly.
Mirror the locale-name derivation already used by NeedsTurkishCasing:
short-circuit tr/az to False directly in the populator, and only fall
through to the CompareInfo probe for other cultures. Also drop the test
guards for Android and LinuxBionic (already cherry-picked as a separate
commit), so the regression is now exercised in CI on all platforms.
@MsfPablo
MsfPabloforce-pushed the turkish-casing-106560 branch from 8979933 to 7a2a403CompareAugust 11, 2026 22:02
@MsfPablo

Copy link
Copy Markdown
Author

Confirmed the hypothesis from point (1) in my last comment — the failing tests never reached IcuChangeCase on Android. The root cause is a separate, second location that also depends on a CompareInfo.Compare probe: PopulateIsAsciiCasingSameAsInvariant in src/libraries/System.Private.CoreLib/src/System/Globalization/TextInfo.cs.

The mechanism. Both overloads of ChangeCaseCommon<TConversion> start with:

if (instance == null || instance.IsAsciiCasingSameAsInvariant)
{
Ascii.ToUpper(source, destination, out charsConsumed); // or ToLower
return; // on success
}

The IsAsciiCasingSameAsInvariant probe runs CompareInfo.Compare("a-z", "A-Z", IgnoreCase) == 0 against the locale's collation. On Android, where the system ICU's collation data is incomplete (the same gap NeedsTurkishCasing was probing around), that comparison incorrectly returns True for tr-TR. The ASCII fast path then takes over and calls Ascii.ToLower('I'), which is hardcoded to U+0069. ChangeCaseCore and therefore IcuChangeCase are never reached, which is why the original NeedsTurkishCasing fix looked correct but had no effect on the test actuals.

The fix (commit 7a2a403) mirrors the locale-name derivation already used by NeedsTurkishCasing: short-circuit tr/az to False in the populator before falling through to the unreliable CompareInfo.Compare probe. The two paths now agree by construction, and either fix alone would still leave the bug present.

Also pushed (6d3b84d): drop the IsAndroid/IsLinuxBionic test guards on the affected ToLower/ToUpper rows so this regression is exercised in CI on every platform going forward.

Sorry it took a follow-up commit rather than getting it right the first time — I should have traced the whole ChangeCaseCommon path before opening, not just IcuChangeCase.

@matouskozak

Copy link
Copy Markdown
Member

@MsfPablo were you able to verify the fix locally? We run thousands of tests on Android CI and as such iterating on a solution using just the CI is inefficient. We have instructions on how to build runtime for Android in https://github.com/dotnet/runtime/blob/main/docs/workflow/building/coreclr/android.md.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.Globalizationcommunity-contributionIndicates that the PR has been added by a community memberos-android

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android - With set Turkish locale "i".ToUpper() is giving 'I" instead of 'İ'

4 participants

@MsfPablo@tarekgh@matouskozak@ilonatommy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Determine Turkish casing from the locale name instead of collation - #132002

Open
MsfPablo wants to merge 3 commits into
dotnet:mainfrom
MsfPablo:turkish-casing-106560
Open

Determine Turkish casing from the locale name instead of collation#132002
MsfPablo wants to merge 3 commits into
dotnet:mainfrom
MsfPablo:turkish-casing-106560

Conversation

@MsfPablo

Copy link
Copy Markdown

Fixes#106560

Problem

On Android, "i".ToUpper(new CultureInfo("tr-TR")) returns I (U+0049) instead of İ (U+0130), and "I".ToLower(...) returns i instead of ı. The same code is correct on Windows/Linux/macOS.

Cause

TextInfo.NeedsTurkishCasing does not look at the locale at all — it infers the need for Turkish casing by probing the collation tailoring:

returnCultureInfo.GetCultureInfo(localeName).CompareInfo.Compare("ı","I",CompareOptions.IgnoreCase)==0;

Android uses the system ICU, which does not carry the tr/az collation tailoring that makes U+0131 compare equal to I under IgnoreCase (the same underlying gap tracked by #60568). The probe therefore returns false for tr-TR, IcuChangeCase takes the ChangeCase path instead of ChangeCaseTurkish, and the dotted/dotless i mapping is silently lost. GlobalizationNative_ChangeCaseTurkish itself is fine and is already built for Android — it is simply never called.

Fix

Decide from the locale's language subtag instead, which is what ICU itself does (ustrcase_getCaseLocale selects UCASE_LOC_TURKISH for languages tr and az). This removes the dependency on collation data and makes the behavior identical on every ICU platform.

This should be behavior-preserving elsewhere: tr and az are exactly the languages whose collation tailoring made the old probe return true, and the root collation does not equate U+0131 with I. The existing en-US-POSIX regression case — which exists precisely because the old check was collation-based — still passes, since its language subtag is en.

Tests

The Turkish ToUpper/ToLower cases in TextInfoTests were skipped on Android/LinuxBionic with the comment "Android has its own ICU, which doesn't work well with tr". Those guards are removed so the cases now run on those platforms and cover this fix. They exercise tr, tr-TR, az and az-Latn-AZ.

Verification

I want to be upfront: I have not built or run this locally. Building dotnet/runtime for Android and running the globalization suite on a device/emulator was not something I could do in my environment, so I am relying on CI (including the Android legs) to validate it. The change is small and self-contained, and the re-enabled test cases are the intended proof. Happy to iterate if CI shows anything unexpected — in particular if some locale I have not considered relied on the old collation-derived answer.

cc @matouskozak@tarekgh@ilonatommy

@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Aug 7, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'arch-android': @vitek-karas, @simonrozsival, @steveisok, @akoeplinger
See info in area-owners.md if you want to be subscribed.

@tarekghtarekgh added this to the 11.0.0 milestone Aug 7, 2026
@tarekgh

Copy link
Copy Markdown
Member

/azp run runtime-extra-platforms

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@matouskozak

matouskozak commented Aug 10, 2026

Copy link
Copy Markdown
Member

Thank you @MsfPablo for looking into this. Not sure if you're able to acces the CI results at: https://dev.azure.com/dnceng-public/public/_build/results?buildId=1544675&view=logs&jobId=c9e87f9f-b882-5f07-e8c2-f8cdb4f422a2&j=c9e87f9f-b882-5f07-e8c2-f8cdb4f422a2&t=f555a9c0-ef97-5d93-0768-acf0622a0bcb

If not, here is the extract of the failing tests:

 08-07 14:55:39.765 8433 8449 I DOTNET : System.Globalization.Tests.OrdinalCasingTests 0.2236177000000000111206515148 ms
08-07 14:55:39.775 8433 8449 I DOTNET : Failed tests:
08-07 14:55:39.776 8433 8449 I DOTNET : 1) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "I", expected: "ı") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "ı"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "i"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0052859999999999999001354389
08-07 14:55:39.776 8433 8449 I DOTNET : 2) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "HI!", expected: "hı!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0001764000000000000051465776
08-07 14:55:39.776 8433 8449 I DOTNET : 3) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "HI\n\0Hİ\t!", expected: "hı\n\0hi\t!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı\n\0hi\t!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi\n\0hi\t!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0001054000000000000005244069
08-07 14:55:39.776 8433 8449 I DOTNET : 4) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "I", expected: "ı") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "ı"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "i"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0020343000000000001706301767
08-07 14:55:39.776 8433 8449 I DOTNET : 5) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "HI!", expected: "hı!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000762999999999999979237095
08-07 14:55:39.777 8433 8449 I DOTNET : 6) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "HI\n\0Hİ\t!", expected: "hı\n\0hi\t!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "hı\n\0hi\t!"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "hi\n\0hi\t!"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000733999999999999951962038
08-07 14:55:39.777 8433 8449 I DOTNET : 7) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToUpper Test name: System.Globalization.Tests.TextInfoMiscTests.ToUpper(name: "tr", str: "i", expected: "İ") Test case: System.Globalization.Tests.TextInfoMiscTests.ToUpper
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "İ"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "I"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToUpper(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0001252000000000000074634743
08-07 14:55:39.777 8433 8449 I DOTNET : 8) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToUpper Test name: System.Globalization.Tests.TextInfoMiscTests.ToUpper(name: "tr-TR", str: "i", expected: "İ") Test case: System.Globalization.Tests.TextInfoMiscTests.ToUpper
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "İ"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "I"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToUpper(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000765000000000000027850985

@MsfPablo

Copy link
Copy Markdown
Author

@dotnet-policy-service agree


Thanks @matouskozak for the CI extract — I can see the same 8 failures land on Android, and the symptoms (Expected: ı / Actual: i, and ditto for U+0130 / U+0049) are exactly the bug the probe was supposed to flush out. The patch's NeedsTurkishCasing should now return true for tr / tr-TR / az/az-Latn-AZ and route IcuChangeCase through GlobalizationNative_ChangeCaseTurkish, which is unconditionally compiled in pal_casing.c (no __ANDROID__ guard around it). The native Turkish mapping itself is correct (U+0049 → U+0131 lower, U+0069 → U+0130 upper).

A few things I want to nail down before I push a follow-up commit, ideally with @tarekgh's CI results replicated locally:

  1. Where the path actually breaks. I want to check whether the failing tests reach IcuChangeCase at all — IsAsciiCasingSameAsInvariant for tr-TR is itself populated via a CompareInfo.Compare probe on line 582 of TextInfo.cs, which has the same Android-collation gap. If that probe ends up True on Android, ChangeCaseCommon takes the ASCII fast path and never calls IcuChangeCase. That's plausible and would explain the actuals — Ascii.ToLower('I') is hardcoded to U+0069.
  2. Build artefacts. Could you confirm that the Android job re-pulled/recompiled System.Private.CoreLib and System.Globalization.Native for this PR? The probe change is in C# but the dispatch is in the native PAL, and a stale AOT snapshot on the device would explain the symptoms exactly.
  3. LinuxBionic. Same tests are skipped on LinuxBionic in the old code with the same comment. Is LinuxBionic also failing, or only Android? If only Android, the native PAL is likely fine and the issue is in the managed dispatch on that platform specifically.

I'll re-examine the IsAsciiCasingSameAsInvariant path tonight and push a follow-up commit that either also derives from the locale name (consistent with the NeedsTurkishCasing decision) or short-circuits tr/az to False directly. If point 2 turns out to be the cause, that's a no-op fix and I'll say so.

Sorry for the CI thrash — I should have at least traced the whole ChangeCaseCommon path before opening, not just IcuChangeCase.

TextInfo.NeedsTurkishCasing detected the Turkish dotted/dotless i casing
rules by probing whether the culture collates U+0131 equal to 'I' under
IgnoreCase. On Android the system ICU does not provide the collation data
that probe depends on, so the check returned false for tr-TR and casing
silently fell back to the non-Turkish path, making i.ToUpper() yield
'I' instead of U+0130.
Fixesdotnet#106560
These ToLower/ToUpper cases were skipped on Android and LinuxBionic
because the collation-based Turkish detection did not work there. The
casing path no longer depends on collation data, so they can run.
The previous commit routed IcuChangeCase through GlobalizationNative_*
ChangeCaseTurkish for tr/az locales by deriving the language from the
locale name. The follow-up CI run on Android still failed with the same
8 tests returning U+0069/U+0049 instead of U+0131/U+0130, which means
IcuChangeCase was never reached at all on that platform.
The culprit is PopulateIsAsciiCasingSameAsInvariant in TextInfo.cs: the
same CompareInfo.Compare probe that NeedsTurkishCasing used to depend on
is also used here to detect whether ASCII casing matches the invariant
culture. On Android, where the system ICU's collation data is
incomplete, the probe incorrectly returns True for tr-TR, so
ChangeCaseCommon's ASCII fast path (line 262 / 321) calls
Ascii.ToLower/ToUpper, which hardcodes the U+0049 -> U+0069 mapping and
never invokes ChangeCaseCore. That matches the failure symptom exactly.
Mirror the locale-name derivation already used by NeedsTurkishCasing:
short-circuit tr/az to False directly in the populator, and only fall
through to the CompareInfo probe for other cultures. Also drop the test
guards for Android and LinuxBionic (already cherry-picked as a separate
commit), so the regression is now exercised in CI on all platforms.
@MsfPablo
MsfPabloforce-pushed the turkish-casing-106560 branch from 8979933 to 7a2a403CompareAugust 11, 2026 22:02
@MsfPablo

Copy link
Copy Markdown
Author

Confirmed the hypothesis from point (1) in my last comment — the failing tests never reached IcuChangeCase on Android. The root cause is a separate, second location that also depends on a CompareInfo.Compare probe: PopulateIsAsciiCasingSameAsInvariant in src/libraries/System.Private.CoreLib/src/System/Globalization/TextInfo.cs.

The mechanism. Both overloads of ChangeCaseCommon<TConversion> start with:

if (instance == null || instance.IsAsciiCasingSameAsInvariant)
{
Ascii.ToUpper(source, destination, out charsConsumed); // or ToLower
return; // on success
}

The IsAsciiCasingSameAsInvariant probe runs CompareInfo.Compare("a-z", "A-Z", IgnoreCase) == 0 against the locale's collation. On Android, where the system ICU's collation data is incomplete (the same gap NeedsTurkishCasing was probing around), that comparison incorrectly returns True for tr-TR. The ASCII fast path then takes over and calls Ascii.ToLower('I'), which is hardcoded to U+0069. ChangeCaseCore and therefore IcuChangeCase are never reached, which is why the original NeedsTurkishCasing fix looked correct but had no effect on the test actuals.

The fix (commit 7a2a403) mirrors the locale-name derivation already used by NeedsTurkishCasing: short-circuit tr/az to False in the populator before falling through to the unreliable CompareInfo.Compare probe. The two paths now agree by construction, and either fix alone would still leave the bug present.

Also pushed (6d3b84d): drop the IsAndroid/IsLinuxBionic test guards on the affected ToLower/ToUpper rows so this regression is exercised in CI on every platform going forward.

Sorry it took a follow-up commit rather than getting it right the first time — I should have traced the whole ChangeCaseCommon path before opening, not just IcuChangeCase.

@matouskozak

Copy link
Copy Markdown
Member

@MsfPablo were you able to verify the fix locally? We run thousands of tests on Android CI and as such iterating on a solution using just the CI is inefficient. We have instructions on how to build runtime for Android in https://github.com/dotnet/runtime/blob/main/docs/workflow/building/coreclr/android.md.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.Globalizationcommunity-contributionIndicates that the PR has been added by a community memberos-android

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android - With set Turkish locale "i".ToUpper() is giving 'I" instead of 'İ'

4 participants

@MsfPablo@tarekgh@matouskozak@ilonatommy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Determine Turkish casing from the locale name instead of collation - #132002

Open
MsfPablo wants to merge 3 commits into
dotnet:mainfrom
MsfPablo:turkish-casing-106560
Open

Determine Turkish casing from the locale name instead of collation#132002
MsfPablo wants to merge 3 commits into
dotnet:mainfrom
MsfPablo:turkish-casing-106560

Conversation

@MsfPablo

Copy link
Copy Markdown

Fixes#106560

Problem

On Android, "i".ToUpper(new CultureInfo("tr-TR")) returns I (U+0049) instead of İ (U+0130), and "I".ToLower(...) returns i instead of ı. The same code is correct on Windows/Linux/macOS.

Cause

TextInfo.NeedsTurkishCasing does not look at the locale at all — it infers the need for Turkish casing by probing the collation tailoring:

returnCultureInfo.GetCultureInfo(localeName).CompareInfo.Compare("ı","I",CompareOptions.IgnoreCase)==0;

Android uses the system ICU, which does not carry the tr/az collation tailoring that makes U+0131 compare equal to I under IgnoreCase (the same underlying gap tracked by #60568). The probe therefore returns false for tr-TR, IcuChangeCase takes the ChangeCase path instead of ChangeCaseTurkish, and the dotted/dotless i mapping is silently lost. GlobalizationNative_ChangeCaseTurkish itself is fine and is already built for Android — it is simply never called.

Fix

Decide from the locale's language subtag instead, which is what ICU itself does (ustrcase_getCaseLocale selects UCASE_LOC_TURKISH for languages tr and az). This removes the dependency on collation data and makes the behavior identical on every ICU platform.

This should be behavior-preserving elsewhere: tr and az are exactly the languages whose collation tailoring made the old probe return true, and the root collation does not equate U+0131 with I. The existing en-US-POSIX regression case — which exists precisely because the old check was collation-based — still passes, since its language subtag is en.

Tests

The Turkish ToUpper/ToLower cases in TextInfoTests were skipped on Android/LinuxBionic with the comment "Android has its own ICU, which doesn't work well with tr". Those guards are removed so the cases now run on those platforms and cover this fix. They exercise tr, tr-TR, az and az-Latn-AZ.

Verification

I want to be upfront: I have not built or run this locally. Building dotnet/runtime for Android and running the globalization suite on a device/emulator was not something I could do in my environment, so I am relying on CI (including the Android legs) to validate it. The change is small and self-contained, and the re-enabled test cases are the intended proof. Happy to iterate if CI shows anything unexpected — in particular if some locale I have not considered relied on the old collation-derived answer.

cc @matouskozak@tarekgh@ilonatommy

@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Aug 7, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'arch-android': @vitek-karas, @simonrozsival, @steveisok, @akoeplinger
See info in area-owners.md if you want to be subscribed.

@tarekghtarekgh added this to the 11.0.0 milestone Aug 7, 2026
@tarekgh

Copy link
Copy Markdown
Member

/azp run runtime-extra-platforms

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@matouskozak

matouskozak commented Aug 10, 2026

Copy link
Copy Markdown
Member

Thank you @MsfPablo for looking into this. Not sure if you're able to acces the CI results at: https://dev.azure.com/dnceng-public/public/_build/results?buildId=1544675&view=logs&jobId=c9e87f9f-b882-5f07-e8c2-f8cdb4f422a2&j=c9e87f9f-b882-5f07-e8c2-f8cdb4f422a2&t=f555a9c0-ef97-5d93-0768-acf0622a0bcb

If not, here is the extract of the failing tests:

 08-07 14:55:39.765 8433 8449 I DOTNET : System.Globalization.Tests.OrdinalCasingTests 0.2236177000000000111206515148 ms
08-07 14:55:39.775 8433 8449 I DOTNET : Failed tests:
08-07 14:55:39.776 8433 8449 I DOTNET : 1) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "I", expected: "ı") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "ı"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "i"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0052859999999999999001354389
08-07 14:55:39.776 8433 8449 I DOTNET : 2) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "HI!", expected: "hı!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0001764000000000000051465776
08-07 14:55:39.776 8433 8449 I DOTNET : 3) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "HI\n\0Hİ\t!", expected: "hı\n\0hi\t!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı\n\0hi\t!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi\n\0hi\t!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0001054000000000000005244069
08-07 14:55:39.776 8433 8449 I DOTNET : 4) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "I", expected: "ı") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "ı"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "i"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0020343000000000001706301767
08-07 14:55:39.776 8433 8449 I DOTNET : 5) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "HI!", expected: "hı!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000762999999999999979237095
08-07 14:55:39.777 8433 8449 I DOTNET : 6) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "HI\n\0Hİ\t!", expected: "hı\n\0hi\t!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "hı\n\0hi\t!"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "hi\n\0hi\t!"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000733999999999999951962038
08-07 14:55:39.777 8433 8449 I DOTNET : 7) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToUpper Test name: System.Globalization.Tests.TextInfoMiscTests.ToUpper(name: "tr", str: "i", expected: "İ") Test case: System.Globalization.Tests.TextInfoMiscTests.ToUpper
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "İ"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "I"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToUpper(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0001252000000000000074634743
08-07 14:55:39.777 8433 8449 I DOTNET : 8) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToUpper Test name: System.Globalization.Tests.TextInfoMiscTests.ToUpper(name: "tr-TR", str: "i", expected: "İ") Test case: System.Globalization.Tests.TextInfoMiscTests.ToUpper
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "İ"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "I"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToUpper(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000765000000000000027850985

@MsfPablo

Copy link
Copy Markdown
Author

@dotnet-policy-service agree


Thanks @matouskozak for the CI extract — I can see the same 8 failures land on Android, and the symptoms (Expected: ı / Actual: i, and ditto for U+0130 / U+0049) are exactly the bug the probe was supposed to flush out. The patch's NeedsTurkishCasing should now return true for tr / tr-TR / az/az-Latn-AZ and route IcuChangeCase through GlobalizationNative_ChangeCaseTurkish, which is unconditionally compiled in pal_casing.c (no __ANDROID__ guard around it). The native Turkish mapping itself is correct (U+0049 → U+0131 lower, U+0069 → U+0130 upper).

A few things I want to nail down before I push a follow-up commit, ideally with @tarekgh's CI results replicated locally:

  1. Where the path actually breaks. I want to check whether the failing tests reach IcuChangeCase at all — IsAsciiCasingSameAsInvariant for tr-TR is itself populated via a CompareInfo.Compare probe on line 582 of TextInfo.cs, which has the same Android-collation gap. If that probe ends up True on Android, ChangeCaseCommon takes the ASCII fast path and never calls IcuChangeCase. That's plausible and would explain the actuals — Ascii.ToLower('I') is hardcoded to U+0069.
  2. Build artefacts. Could you confirm that the Android job re-pulled/recompiled System.Private.CoreLib and System.Globalization.Native for this PR? The probe change is in C# but the dispatch is in the native PAL, and a stale AOT snapshot on the device would explain the symptoms exactly.
  3. LinuxBionic. Same tests are skipped on LinuxBionic in the old code with the same comment. Is LinuxBionic also failing, or only Android? If only Android, the native PAL is likely fine and the issue is in the managed dispatch on that platform specifically.

I'll re-examine the IsAsciiCasingSameAsInvariant path tonight and push a follow-up commit that either also derives from the locale name (consistent with the NeedsTurkishCasing decision) or short-circuits tr/az to False directly. If point 2 turns out to be the cause, that's a no-op fix and I'll say so.

Sorry for the CI thrash — I should have at least traced the whole ChangeCaseCommon path before opening, not just IcuChangeCase.

TextInfo.NeedsTurkishCasing detected the Turkish dotted/dotless i casing
rules by probing whether the culture collates U+0131 equal to 'I' under
IgnoreCase. On Android the system ICU does not provide the collation data
that probe depends on, so the check returned false for tr-TR and casing
silently fell back to the non-Turkish path, making i.ToUpper() yield
'I' instead of U+0130.
Fixesdotnet#106560
These ToLower/ToUpper cases were skipped on Android and LinuxBionic
because the collation-based Turkish detection did not work there. The
casing path no longer depends on collation data, so they can run.
The previous commit routed IcuChangeCase through GlobalizationNative_*
ChangeCaseTurkish for tr/az locales by deriving the language from the
locale name. The follow-up CI run on Android still failed with the same
8 tests returning U+0069/U+0049 instead of U+0131/U+0130, which means
IcuChangeCase was never reached at all on that platform.
The culprit is PopulateIsAsciiCasingSameAsInvariant in TextInfo.cs: the
same CompareInfo.Compare probe that NeedsTurkishCasing used to depend on
is also used here to detect whether ASCII casing matches the invariant
culture. On Android, where the system ICU's collation data is
incomplete, the probe incorrectly returns True for tr-TR, so
ChangeCaseCommon's ASCII fast path (line 262 / 321) calls
Ascii.ToLower/ToUpper, which hardcodes the U+0049 -> U+0069 mapping and
never invokes ChangeCaseCore. That matches the failure symptom exactly.
Mirror the locale-name derivation already used by NeedsTurkishCasing:
short-circuit tr/az to False directly in the populator, and only fall
through to the CompareInfo probe for other cultures. Also drop the test
guards for Android and LinuxBionic (already cherry-picked as a separate
commit), so the regression is now exercised in CI on all platforms.
@MsfPablo
MsfPabloforce-pushed the turkish-casing-106560 branch from 8979933 to 7a2a403CompareAugust 11, 2026 22:02
@MsfPablo

Copy link
Copy Markdown
Author

Confirmed the hypothesis from point (1) in my last comment — the failing tests never reached IcuChangeCase on Android. The root cause is a separate, second location that also depends on a CompareInfo.Compare probe: PopulateIsAsciiCasingSameAsInvariant in src/libraries/System.Private.CoreLib/src/System/Globalization/TextInfo.cs.

The mechanism. Both overloads of ChangeCaseCommon<TConversion> start with:

if (instance == null || instance.IsAsciiCasingSameAsInvariant)
{
Ascii.ToUpper(source, destination, out charsConsumed); // or ToLower
return; // on success
}

The IsAsciiCasingSameAsInvariant probe runs CompareInfo.Compare("a-z", "A-Z", IgnoreCase) == 0 against the locale's collation. On Android, where the system ICU's collation data is incomplete (the same gap NeedsTurkishCasing was probing around), that comparison incorrectly returns True for tr-TR. The ASCII fast path then takes over and calls Ascii.ToLower('I'), which is hardcoded to U+0069. ChangeCaseCore and therefore IcuChangeCase are never reached, which is why the original NeedsTurkishCasing fix looked correct but had no effect on the test actuals.

The fix (commit 7a2a403) mirrors the locale-name derivation already used by NeedsTurkishCasing: short-circuit tr/az to False in the populator before falling through to the unreliable CompareInfo.Compare probe. The two paths now agree by construction, and either fix alone would still leave the bug present.

Also pushed (6d3b84d): drop the IsAndroid/IsLinuxBionic test guards on the affected ToLower/ToUpper rows so this regression is exercised in CI on every platform going forward.

Sorry it took a follow-up commit rather than getting it right the first time — I should have traced the whole ChangeCaseCommon path before opening, not just IcuChangeCase.

@matouskozak

Copy link
Copy Markdown
Member

@MsfPablo were you able to verify the fix locally? We run thousands of tests on Android CI and as such iterating on a solution using just the CI is inefficient. We have instructions on how to build runtime for Android in https://github.com/dotnet/runtime/blob/main/docs/workflow/building/coreclr/android.md.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.Globalizationcommunity-contributionIndicates that the PR has been added by a community memberos-android

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android - With set Turkish locale "i".ToUpper() is giving 'I" instead of 'İ'

4 participants

@MsfPablo@tarekgh@matouskozak@ilonatommy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Determine Turkish casing from the locale name instead of collation - #132002

Open
MsfPablo wants to merge 3 commits into
dotnet:mainfrom
MsfPablo:turkish-casing-106560
Open

Determine Turkish casing from the locale name instead of collation#132002
MsfPablo wants to merge 3 commits into
dotnet:mainfrom
MsfPablo:turkish-casing-106560

Conversation

@MsfPablo

Copy link
Copy Markdown

Fixes#106560

Problem

On Android, "i".ToUpper(new CultureInfo("tr-TR")) returns I (U+0049) instead of İ (U+0130), and "I".ToLower(...) returns i instead of ı. The same code is correct on Windows/Linux/macOS.

Cause

TextInfo.NeedsTurkishCasing does not look at the locale at all — it infers the need for Turkish casing by probing the collation tailoring:

returnCultureInfo.GetCultureInfo(localeName).CompareInfo.Compare("ı","I",CompareOptions.IgnoreCase)==0;

Android uses the system ICU, which does not carry the tr/az collation tailoring that makes U+0131 compare equal to I under IgnoreCase (the same underlying gap tracked by #60568). The probe therefore returns false for tr-TR, IcuChangeCase takes the ChangeCase path instead of ChangeCaseTurkish, and the dotted/dotless i mapping is silently lost. GlobalizationNative_ChangeCaseTurkish itself is fine and is already built for Android — it is simply never called.

Fix

Decide from the locale's language subtag instead, which is what ICU itself does (ustrcase_getCaseLocale selects UCASE_LOC_TURKISH for languages tr and az). This removes the dependency on collation data and makes the behavior identical on every ICU platform.

This should be behavior-preserving elsewhere: tr and az are exactly the languages whose collation tailoring made the old probe return true, and the root collation does not equate U+0131 with I. The existing en-US-POSIX regression case — which exists precisely because the old check was collation-based — still passes, since its language subtag is en.

Tests

The Turkish ToUpper/ToLower cases in TextInfoTests were skipped on Android/LinuxBionic with the comment "Android has its own ICU, which doesn't work well with tr". Those guards are removed so the cases now run on those platforms and cover this fix. They exercise tr, tr-TR, az and az-Latn-AZ.

Verification

I want to be upfront: I have not built or run this locally. Building dotnet/runtime for Android and running the globalization suite on a device/emulator was not something I could do in my environment, so I am relying on CI (including the Android legs) to validate it. The change is small and self-contained, and the re-enabled test cases are the intended proof. Happy to iterate if CI shows anything unexpected — in particular if some locale I have not considered relied on the old collation-derived answer.

cc @matouskozak@tarekgh@ilonatommy

@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Aug 7, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'arch-android': @vitek-karas, @simonrozsival, @steveisok, @akoeplinger
See info in area-owners.md if you want to be subscribed.

@tarekghtarekgh added this to the 11.0.0 milestone Aug 7, 2026
@tarekgh

Copy link
Copy Markdown
Member

/azp run runtime-extra-platforms

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@matouskozak

matouskozak commented Aug 10, 2026

Copy link
Copy Markdown
Member

Thank you @MsfPablo for looking into this. Not sure if you're able to acces the CI results at: https://dev.azure.com/dnceng-public/public/_build/results?buildId=1544675&view=logs&jobId=c9e87f9f-b882-5f07-e8c2-f8cdb4f422a2&j=c9e87f9f-b882-5f07-e8c2-f8cdb4f422a2&t=f555a9c0-ef97-5d93-0768-acf0622a0bcb

If not, here is the extract of the failing tests:

 08-07 14:55:39.765 8433 8449 I DOTNET : System.Globalization.Tests.OrdinalCasingTests 0.2236177000000000111206515148 ms
08-07 14:55:39.775 8433 8449 I DOTNET : Failed tests:
08-07 14:55:39.776 8433 8449 I DOTNET : 1) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "I", expected: "ı") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "ı"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "i"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0052859999999999999001354389
08-07 14:55:39.776 8433 8449 I DOTNET : 2) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "HI!", expected: "hı!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0001764000000000000051465776
08-07 14:55:39.776 8433 8449 I DOTNET : 3) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "HI\n\0Hİ\t!", expected: "hı\n\0hi\t!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı\n\0hi\t!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi\n\0hi\t!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0001054000000000000005244069
08-07 14:55:39.776 8433 8449 I DOTNET : 4) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "I", expected: "ı") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "ı"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "i"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0020343000000000001706301767
08-07 14:55:39.776 8433 8449 I DOTNET : 5) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "HI!", expected: "hı!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000762999999999999979237095
08-07 14:55:39.777 8433 8449 I DOTNET : 6) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "HI\n\0Hİ\t!", expected: "hı\n\0hi\t!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "hı\n\0hi\t!"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "hi\n\0hi\t!"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000733999999999999951962038
08-07 14:55:39.777 8433 8449 I DOTNET : 7) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToUpper Test name: System.Globalization.Tests.TextInfoMiscTests.ToUpper(name: "tr", str: "i", expected: "İ") Test case: System.Globalization.Tests.TextInfoMiscTests.ToUpper
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "İ"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "I"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToUpper(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0001252000000000000074634743
08-07 14:55:39.777 8433 8449 I DOTNET : 8) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToUpper Test name: System.Globalization.Tests.TextInfoMiscTests.ToUpper(name: "tr-TR", str: "i", expected: "İ") Test case: System.Globalization.Tests.TextInfoMiscTests.ToUpper
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "İ"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "I"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToUpper(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000765000000000000027850985

@MsfPablo

Copy link
Copy Markdown
Author

@dotnet-policy-service agree


Thanks @matouskozak for the CI extract — I can see the same 8 failures land on Android, and the symptoms (Expected: ı / Actual: i, and ditto for U+0130 / U+0049) are exactly the bug the probe was supposed to flush out. The patch's NeedsTurkishCasing should now return true for tr / tr-TR / az/az-Latn-AZ and route IcuChangeCase through GlobalizationNative_ChangeCaseTurkish, which is unconditionally compiled in pal_casing.c (no __ANDROID__ guard around it). The native Turkish mapping itself is correct (U+0049 → U+0131 lower, U+0069 → U+0130 upper).

A few things I want to nail down before I push a follow-up commit, ideally with @tarekgh's CI results replicated locally:

  1. Where the path actually breaks. I want to check whether the failing tests reach IcuChangeCase at all — IsAsciiCasingSameAsInvariant for tr-TR is itself populated via a CompareInfo.Compare probe on line 582 of TextInfo.cs, which has the same Android-collation gap. If that probe ends up True on Android, ChangeCaseCommon takes the ASCII fast path and never calls IcuChangeCase. That's plausible and would explain the actuals — Ascii.ToLower('I') is hardcoded to U+0069.
  2. Build artefacts. Could you confirm that the Android job re-pulled/recompiled System.Private.CoreLib and System.Globalization.Native for this PR? The probe change is in C# but the dispatch is in the native PAL, and a stale AOT snapshot on the device would explain the symptoms exactly.
  3. LinuxBionic. Same tests are skipped on LinuxBionic in the old code with the same comment. Is LinuxBionic also failing, or only Android? If only Android, the native PAL is likely fine and the issue is in the managed dispatch on that platform specifically.

I'll re-examine the IsAsciiCasingSameAsInvariant path tonight and push a follow-up commit that either also derives from the locale name (consistent with the NeedsTurkishCasing decision) or short-circuits tr/az to False directly. If point 2 turns out to be the cause, that's a no-op fix and I'll say so.

Sorry for the CI thrash — I should have at least traced the whole ChangeCaseCommon path before opening, not just IcuChangeCase.

TextInfo.NeedsTurkishCasing detected the Turkish dotted/dotless i casing
rules by probing whether the culture collates U+0131 equal to 'I' under
IgnoreCase. On Android the system ICU does not provide the collation data
that probe depends on, so the check returned false for tr-TR and casing
silently fell back to the non-Turkish path, making i.ToUpper() yield
'I' instead of U+0130.
Fixesdotnet#106560
These ToLower/ToUpper cases were skipped on Android and LinuxBionic
because the collation-based Turkish detection did not work there. The
casing path no longer depends on collation data, so they can run.
The previous commit routed IcuChangeCase through GlobalizationNative_*
ChangeCaseTurkish for tr/az locales by deriving the language from the
locale name. The follow-up CI run on Android still failed with the same
8 tests returning U+0069/U+0049 instead of U+0131/U+0130, which means
IcuChangeCase was never reached at all on that platform.
The culprit is PopulateIsAsciiCasingSameAsInvariant in TextInfo.cs: the
same CompareInfo.Compare probe that NeedsTurkishCasing used to depend on
is also used here to detect whether ASCII casing matches the invariant
culture. On Android, where the system ICU's collation data is
incomplete, the probe incorrectly returns True for tr-TR, so
ChangeCaseCommon's ASCII fast path (line 262 / 321) calls
Ascii.ToLower/ToUpper, which hardcodes the U+0049 -> U+0069 mapping and
never invokes ChangeCaseCore. That matches the failure symptom exactly.
Mirror the locale-name derivation already used by NeedsTurkishCasing:
short-circuit tr/az to False directly in the populator, and only fall
through to the CompareInfo probe for other cultures. Also drop the test
guards for Android and LinuxBionic (already cherry-picked as a separate
commit), so the regression is now exercised in CI on all platforms.
@MsfPablo
MsfPabloforce-pushed the turkish-casing-106560 branch from 8979933 to 7a2a403CompareAugust 11, 2026 22:02
@MsfPablo

Copy link
Copy Markdown
Author

Confirmed the hypothesis from point (1) in my last comment — the failing tests never reached IcuChangeCase on Android. The root cause is a separate, second location that also depends on a CompareInfo.Compare probe: PopulateIsAsciiCasingSameAsInvariant in src/libraries/System.Private.CoreLib/src/System/Globalization/TextInfo.cs.

The mechanism. Both overloads of ChangeCaseCommon<TConversion> start with:

if (instance == null || instance.IsAsciiCasingSameAsInvariant)
{
Ascii.ToUpper(source, destination, out charsConsumed); // or ToLower
return; // on success
}

The IsAsciiCasingSameAsInvariant probe runs CompareInfo.Compare("a-z", "A-Z", IgnoreCase) == 0 against the locale's collation. On Android, where the system ICU's collation data is incomplete (the same gap NeedsTurkishCasing was probing around), that comparison incorrectly returns True for tr-TR. The ASCII fast path then takes over and calls Ascii.ToLower('I'), which is hardcoded to U+0069. ChangeCaseCore and therefore IcuChangeCase are never reached, which is why the original NeedsTurkishCasing fix looked correct but had no effect on the test actuals.

The fix (commit 7a2a403) mirrors the locale-name derivation already used by NeedsTurkishCasing: short-circuit tr/az to False in the populator before falling through to the unreliable CompareInfo.Compare probe. The two paths now agree by construction, and either fix alone would still leave the bug present.

Also pushed (6d3b84d): drop the IsAndroid/IsLinuxBionic test guards on the affected ToLower/ToUpper rows so this regression is exercised in CI on every platform going forward.

Sorry it took a follow-up commit rather than getting it right the first time — I should have traced the whole ChangeCaseCommon path before opening, not just IcuChangeCase.

@matouskozak

Copy link
Copy Markdown
Member

@MsfPablo were you able to verify the fix locally? We run thousands of tests on Android CI and as such iterating on a solution using just the CI is inefficient. We have instructions on how to build runtime for Android in https://github.com/dotnet/runtime/blob/main/docs/workflow/building/coreclr/android.md.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.Globalizationcommunity-contributionIndicates that the PR has been added by a community memberos-android

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android - With set Turkish locale "i".ToUpper() is giving 'I" instead of 'İ'

4 participants

@MsfPablo@tarekgh@matouskozak@ilonatommy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Determine Turkish casing from the locale name instead of collation - #132002

Open
MsfPablo wants to merge 3 commits into
dotnet:mainfrom
MsfPablo:turkish-casing-106560
Open

Determine Turkish casing from the locale name instead of collation#132002
MsfPablo wants to merge 3 commits into
dotnet:mainfrom
MsfPablo:turkish-casing-106560

Conversation

@MsfPablo

Copy link
Copy Markdown

Fixes#106560

Problem

On Android, "i".ToUpper(new CultureInfo("tr-TR")) returns I (U+0049) instead of İ (U+0130), and "I".ToLower(...) returns i instead of ı. The same code is correct on Windows/Linux/macOS.

Cause

TextInfo.NeedsTurkishCasing does not look at the locale at all — it infers the need for Turkish casing by probing the collation tailoring:

returnCultureInfo.GetCultureInfo(localeName).CompareInfo.Compare("ı","I",CompareOptions.IgnoreCase)==0;

Android uses the system ICU, which does not carry the tr/az collation tailoring that makes U+0131 compare equal to I under IgnoreCase (the same underlying gap tracked by #60568). The probe therefore returns false for tr-TR, IcuChangeCase takes the ChangeCase path instead of ChangeCaseTurkish, and the dotted/dotless i mapping is silently lost. GlobalizationNative_ChangeCaseTurkish itself is fine and is already built for Android — it is simply never called.

Fix

Decide from the locale's language subtag instead, which is what ICU itself does (ustrcase_getCaseLocale selects UCASE_LOC_TURKISH for languages tr and az). This removes the dependency on collation data and makes the behavior identical on every ICU platform.

This should be behavior-preserving elsewhere: tr and az are exactly the languages whose collation tailoring made the old probe return true, and the root collation does not equate U+0131 with I. The existing en-US-POSIX regression case — which exists precisely because the old check was collation-based — still passes, since its language subtag is en.

Tests

The Turkish ToUpper/ToLower cases in TextInfoTests were skipped on Android/LinuxBionic with the comment "Android has its own ICU, which doesn't work well with tr". Those guards are removed so the cases now run on those platforms and cover this fix. They exercise tr, tr-TR, az and az-Latn-AZ.

Verification

I want to be upfront: I have not built or run this locally. Building dotnet/runtime for Android and running the globalization suite on a device/emulator was not something I could do in my environment, so I am relying on CI (including the Android legs) to validate it. The change is small and self-contained, and the re-enabled test cases are the intended proof. Happy to iterate if CI shows anything unexpected — in particular if some locale I have not considered relied on the old collation-derived answer.

cc @matouskozak@tarekgh@ilonatommy

@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Aug 7, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'arch-android': @vitek-karas, @simonrozsival, @steveisok, @akoeplinger
See info in area-owners.md if you want to be subscribed.

@tarekghtarekgh added this to the 11.0.0 milestone Aug 7, 2026
@tarekgh

Copy link
Copy Markdown
Member

/azp run runtime-extra-platforms

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@matouskozak

matouskozak commented Aug 10, 2026

Copy link
Copy Markdown
Member

Thank you @MsfPablo for looking into this. Not sure if you're able to acces the CI results at: https://dev.azure.com/dnceng-public/public/_build/results?buildId=1544675&view=logs&jobId=c9e87f9f-b882-5f07-e8c2-f8cdb4f422a2&j=c9e87f9f-b882-5f07-e8c2-f8cdb4f422a2&t=f555a9c0-ef97-5d93-0768-acf0622a0bcb

If not, here is the extract of the failing tests:

 08-07 14:55:39.765 8433 8449 I DOTNET : System.Globalization.Tests.OrdinalCasingTests 0.2236177000000000111206515148 ms
08-07 14:55:39.775 8433 8449 I DOTNET : Failed tests:
08-07 14:55:39.776 8433 8449 I DOTNET : 1) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "I", expected: "ı") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "ı"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "i"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0052859999999999999001354389
08-07 14:55:39.776 8433 8449 I DOTNET : 2) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "HI!", expected: "hı!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0001764000000000000051465776
08-07 14:55:39.776 8433 8449 I DOTNET : 3) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "HI\n\0Hİ\t!", expected: "hı\n\0hi\t!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı\n\0hi\t!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi\n\0hi\t!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0001054000000000000005244069
08-07 14:55:39.776 8433 8449 I DOTNET : 4) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "I", expected: "ı") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "ı"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "i"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0020343000000000001706301767
08-07 14:55:39.776 8433 8449 I DOTNET : 5) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "HI!", expected: "hı!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000762999999999999979237095
08-07 14:55:39.777 8433 8449 I DOTNET : 6) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "HI\n\0Hİ\t!", expected: "hı\n\0hi\t!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "hı\n\0hi\t!"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "hi\n\0hi\t!"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000733999999999999951962038
08-07 14:55:39.777 8433 8449 I DOTNET : 7) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToUpper Test name: System.Globalization.Tests.TextInfoMiscTests.ToUpper(name: "tr", str: "i", expected: "İ") Test case: System.Globalization.Tests.TextInfoMiscTests.ToUpper
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "İ"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "I"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToUpper(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0001252000000000000074634743
08-07 14:55:39.777 8433 8449 I DOTNET : 8) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToUpper Test name: System.Globalization.Tests.TextInfoMiscTests.ToUpper(name: "tr-TR", str: "i", expected: "İ") Test case: System.Globalization.Tests.TextInfoMiscTests.ToUpper
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "İ"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "I"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToUpper(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000765000000000000027850985

@MsfPablo

Copy link
Copy Markdown
Author

@dotnet-policy-service agree


Thanks @matouskozak for the CI extract — I can see the same 8 failures land on Android, and the symptoms (Expected: ı / Actual: i, and ditto for U+0130 / U+0049) are exactly the bug the probe was supposed to flush out. The patch's NeedsTurkishCasing should now return true for tr / tr-TR / az/az-Latn-AZ and route IcuChangeCase through GlobalizationNative_ChangeCaseTurkish, which is unconditionally compiled in pal_casing.c (no __ANDROID__ guard around it). The native Turkish mapping itself is correct (U+0049 → U+0131 lower, U+0069 → U+0130 upper).

A few things I want to nail down before I push a follow-up commit, ideally with @tarekgh's CI results replicated locally:

  1. Where the path actually breaks. I want to check whether the failing tests reach IcuChangeCase at all — IsAsciiCasingSameAsInvariant for tr-TR is itself populated via a CompareInfo.Compare probe on line 582 of TextInfo.cs, which has the same Android-collation gap. If that probe ends up True on Android, ChangeCaseCommon takes the ASCII fast path and never calls IcuChangeCase. That's plausible and would explain the actuals — Ascii.ToLower('I') is hardcoded to U+0069.
  2. Build artefacts. Could you confirm that the Android job re-pulled/recompiled System.Private.CoreLib and System.Globalization.Native for this PR? The probe change is in C# but the dispatch is in the native PAL, and a stale AOT snapshot on the device would explain the symptoms exactly.
  3. LinuxBionic. Same tests are skipped on LinuxBionic in the old code with the same comment. Is LinuxBionic also failing, or only Android? If only Android, the native PAL is likely fine and the issue is in the managed dispatch on that platform specifically.

I'll re-examine the IsAsciiCasingSameAsInvariant path tonight and push a follow-up commit that either also derives from the locale name (consistent with the NeedsTurkishCasing decision) or short-circuits tr/az to False directly. If point 2 turns out to be the cause, that's a no-op fix and I'll say so.

Sorry for the CI thrash — I should have at least traced the whole ChangeCaseCommon path before opening, not just IcuChangeCase.

TextInfo.NeedsTurkishCasing detected the Turkish dotted/dotless i casing
rules by probing whether the culture collates U+0131 equal to 'I' under
IgnoreCase. On Android the system ICU does not provide the collation data
that probe depends on, so the check returned false for tr-TR and casing
silently fell back to the non-Turkish path, making i.ToUpper() yield
'I' instead of U+0130.
Fixesdotnet#106560
These ToLower/ToUpper cases were skipped on Android and LinuxBionic
because the collation-based Turkish detection did not work there. The
casing path no longer depends on collation data, so they can run.
The previous commit routed IcuChangeCase through GlobalizationNative_*
ChangeCaseTurkish for tr/az locales by deriving the language from the
locale name. The follow-up CI run on Android still failed with the same
8 tests returning U+0069/U+0049 instead of U+0131/U+0130, which means
IcuChangeCase was never reached at all on that platform.
The culprit is PopulateIsAsciiCasingSameAsInvariant in TextInfo.cs: the
same CompareInfo.Compare probe that NeedsTurkishCasing used to depend on
is also used here to detect whether ASCII casing matches the invariant
culture. On Android, where the system ICU's collation data is
incomplete, the probe incorrectly returns True for tr-TR, so
ChangeCaseCommon's ASCII fast path (line 262 / 321) calls
Ascii.ToLower/ToUpper, which hardcodes the U+0049 -> U+0069 mapping and
never invokes ChangeCaseCore. That matches the failure symptom exactly.
Mirror the locale-name derivation already used by NeedsTurkishCasing:
short-circuit tr/az to False directly in the populator, and only fall
through to the CompareInfo probe for other cultures. Also drop the test
guards for Android and LinuxBionic (already cherry-picked as a separate
commit), so the regression is now exercised in CI on all platforms.
@MsfPablo
MsfPabloforce-pushed the turkish-casing-106560 branch from 8979933 to 7a2a403CompareAugust 11, 2026 22:02
@MsfPablo

Copy link
Copy Markdown
Author

Confirmed the hypothesis from point (1) in my last comment — the failing tests never reached IcuChangeCase on Android. The root cause is a separate, second location that also depends on a CompareInfo.Compare probe: PopulateIsAsciiCasingSameAsInvariant in src/libraries/System.Private.CoreLib/src/System/Globalization/TextInfo.cs.

The mechanism. Both overloads of ChangeCaseCommon<TConversion> start with:

if (instance == null || instance.IsAsciiCasingSameAsInvariant)
{
Ascii.ToUpper(source, destination, out charsConsumed); // or ToLower
return; // on success
}

The IsAsciiCasingSameAsInvariant probe runs CompareInfo.Compare("a-z", "A-Z", IgnoreCase) == 0 against the locale's collation. On Android, where the system ICU's collation data is incomplete (the same gap NeedsTurkishCasing was probing around), that comparison incorrectly returns True for tr-TR. The ASCII fast path then takes over and calls Ascii.ToLower('I'), which is hardcoded to U+0069. ChangeCaseCore and therefore IcuChangeCase are never reached, which is why the original NeedsTurkishCasing fix looked correct but had no effect on the test actuals.

The fix (commit 7a2a403) mirrors the locale-name derivation already used by NeedsTurkishCasing: short-circuit tr/az to False in the populator before falling through to the unreliable CompareInfo.Compare probe. The two paths now agree by construction, and either fix alone would still leave the bug present.

Also pushed (6d3b84d): drop the IsAndroid/IsLinuxBionic test guards on the affected ToLower/ToUpper rows so this regression is exercised in CI on every platform going forward.

Sorry it took a follow-up commit rather than getting it right the first time — I should have traced the whole ChangeCaseCommon path before opening, not just IcuChangeCase.

@matouskozak

Copy link
Copy Markdown
Member

@MsfPablo were you able to verify the fix locally? We run thousands of tests on Android CI and as such iterating on a solution using just the CI is inefficient. We have instructions on how to build runtime for Android in https://github.com/dotnet/runtime/blob/main/docs/workflow/building/coreclr/android.md.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.Globalizationcommunity-contributionIndicates that the PR has been added by a community memberos-android

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android - With set Turkish locale "i".ToUpper() is giving 'I" instead of 'İ'

4 participants

@MsfPablo@tarekgh@matouskozak@ilonatommy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Determine Turkish casing from the locale name instead of collation - #132002

Open
MsfPablo wants to merge 3 commits into
dotnet:mainfrom
MsfPablo:turkish-casing-106560
Open

Determine Turkish casing from the locale name instead of collation#132002
MsfPablo wants to merge 3 commits into
dotnet:mainfrom
MsfPablo:turkish-casing-106560

Conversation

@MsfPablo

Copy link
Copy Markdown

Fixes#106560

Problem

On Android, "i".ToUpper(new CultureInfo("tr-TR")) returns I (U+0049) instead of İ (U+0130), and "I".ToLower(...) returns i instead of ı. The same code is correct on Windows/Linux/macOS.

Cause

TextInfo.NeedsTurkishCasing does not look at the locale at all — it infers the need for Turkish casing by probing the collation tailoring:

returnCultureInfo.GetCultureInfo(localeName).CompareInfo.Compare("ı","I",CompareOptions.IgnoreCase)==0;

Android uses the system ICU, which does not carry the tr/az collation tailoring that makes U+0131 compare equal to I under IgnoreCase (the same underlying gap tracked by #60568). The probe therefore returns false for tr-TR, IcuChangeCase takes the ChangeCase path instead of ChangeCaseTurkish, and the dotted/dotless i mapping is silently lost. GlobalizationNative_ChangeCaseTurkish itself is fine and is already built for Android — it is simply never called.

Fix

Decide from the locale's language subtag instead, which is what ICU itself does (ustrcase_getCaseLocale selects UCASE_LOC_TURKISH for languages tr and az). This removes the dependency on collation data and makes the behavior identical on every ICU platform.

This should be behavior-preserving elsewhere: tr and az are exactly the languages whose collation tailoring made the old probe return true, and the root collation does not equate U+0131 with I. The existing en-US-POSIX regression case — which exists precisely because the old check was collation-based — still passes, since its language subtag is en.

Tests

The Turkish ToUpper/ToLower cases in TextInfoTests were skipped on Android/LinuxBionic with the comment "Android has its own ICU, which doesn't work well with tr". Those guards are removed so the cases now run on those platforms and cover this fix. They exercise tr, tr-TR, az and az-Latn-AZ.

Verification

I want to be upfront: I have not built or run this locally. Building dotnet/runtime for Android and running the globalization suite on a device/emulator was not something I could do in my environment, so I am relying on CI (including the Android legs) to validate it. The change is small and self-contained, and the re-enabled test cases are the intended proof. Happy to iterate if CI shows anything unexpected — in particular if some locale I have not considered relied on the old collation-derived answer.

cc @matouskozak@tarekgh@ilonatommy

@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Aug 7, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'arch-android': @vitek-karas, @simonrozsival, @steveisok, @akoeplinger
See info in area-owners.md if you want to be subscribed.

@tarekghtarekgh added this to the 11.0.0 milestone Aug 7, 2026
@tarekgh

Copy link
Copy Markdown
Member

/azp run runtime-extra-platforms

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@matouskozak

matouskozak commented Aug 10, 2026

Copy link
Copy Markdown
Member

Thank you @MsfPablo for looking into this. Not sure if you're able to acces the CI results at: https://dev.azure.com/dnceng-public/public/_build/results?buildId=1544675&view=logs&jobId=c9e87f9f-b882-5f07-e8c2-f8cdb4f422a2&j=c9e87f9f-b882-5f07-e8c2-f8cdb4f422a2&t=f555a9c0-ef97-5d93-0768-acf0622a0bcb

If not, here is the extract of the failing tests:

 08-07 14:55:39.765 8433 8449 I DOTNET : System.Globalization.Tests.OrdinalCasingTests 0.2236177000000000111206515148 ms
08-07 14:55:39.775 8433 8449 I DOTNET : Failed tests:
08-07 14:55:39.776 8433 8449 I DOTNET : 1) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "I", expected: "ı") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "ı"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "i"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0052859999999999999001354389
08-07 14:55:39.776 8433 8449 I DOTNET : 2) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "HI!", expected: "hı!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0001764000000000000051465776
08-07 14:55:39.776 8433 8449 I DOTNET : 3) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "HI\n\0Hİ\t!", expected: "hı\n\0hi\t!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı\n\0hi\t!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi\n\0hi\t!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0001054000000000000005244069
08-07 14:55:39.776 8433 8449 I DOTNET : 4) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "I", expected: "ı") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "ı"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "i"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0020343000000000001706301767
08-07 14:55:39.776 8433 8449 I DOTNET : 5) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "HI!", expected: "hı!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000762999999999999979237095
08-07 14:55:39.777 8433 8449 I DOTNET : 6) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "HI\n\0Hİ\t!", expected: "hı\n\0hi\t!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "hı\n\0hi\t!"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "hi\n\0hi\t!"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000733999999999999951962038
08-07 14:55:39.777 8433 8449 I DOTNET : 7) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToUpper Test name: System.Globalization.Tests.TextInfoMiscTests.ToUpper(name: "tr", str: "i", expected: "İ") Test case: System.Globalization.Tests.TextInfoMiscTests.ToUpper
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "İ"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "I"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToUpper(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0001252000000000000074634743
08-07 14:55:39.777 8433 8449 I DOTNET : 8) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToUpper Test name: System.Globalization.Tests.TextInfoMiscTests.ToUpper(name: "tr-TR", str: "i", expected: "İ") Test case: System.Globalization.Tests.TextInfoMiscTests.ToUpper
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "İ"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "I"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToUpper(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000765000000000000027850985

@MsfPablo

Copy link
Copy Markdown
Author

@dotnet-policy-service agree


Thanks @matouskozak for the CI extract — I can see the same 8 failures land on Android, and the symptoms (Expected: ı / Actual: i, and ditto for U+0130 / U+0049) are exactly the bug the probe was supposed to flush out. The patch's NeedsTurkishCasing should now return true for tr / tr-TR / az/az-Latn-AZ and route IcuChangeCase through GlobalizationNative_ChangeCaseTurkish, which is unconditionally compiled in pal_casing.c (no __ANDROID__ guard around it). The native Turkish mapping itself is correct (U+0049 → U+0131 lower, U+0069 → U+0130 upper).

A few things I want to nail down before I push a follow-up commit, ideally with @tarekgh's CI results replicated locally:

  1. Where the path actually breaks. I want to check whether the failing tests reach IcuChangeCase at all — IsAsciiCasingSameAsInvariant for tr-TR is itself populated via a CompareInfo.Compare probe on line 582 of TextInfo.cs, which has the same Android-collation gap. If that probe ends up True on Android, ChangeCaseCommon takes the ASCII fast path and never calls IcuChangeCase. That's plausible and would explain the actuals — Ascii.ToLower('I') is hardcoded to U+0069.
  2. Build artefacts. Could you confirm that the Android job re-pulled/recompiled System.Private.CoreLib and System.Globalization.Native for this PR? The probe change is in C# but the dispatch is in the native PAL, and a stale AOT snapshot on the device would explain the symptoms exactly.
  3. LinuxBionic. Same tests are skipped on LinuxBionic in the old code with the same comment. Is LinuxBionic also failing, or only Android? If only Android, the native PAL is likely fine and the issue is in the managed dispatch on that platform specifically.

I'll re-examine the IsAsciiCasingSameAsInvariant path tonight and push a follow-up commit that either also derives from the locale name (consistent with the NeedsTurkishCasing decision) or short-circuits tr/az to False directly. If point 2 turns out to be the cause, that's a no-op fix and I'll say so.

Sorry for the CI thrash — I should have at least traced the whole ChangeCaseCommon path before opening, not just IcuChangeCase.

TextInfo.NeedsTurkishCasing detected the Turkish dotted/dotless i casing
rules by probing whether the culture collates U+0131 equal to 'I' under
IgnoreCase. On Android the system ICU does not provide the collation data
that probe depends on, so the check returned false for tr-TR and casing
silently fell back to the non-Turkish path, making i.ToUpper() yield
'I' instead of U+0130.
Fixesdotnet#106560
These ToLower/ToUpper cases were skipped on Android and LinuxBionic
because the collation-based Turkish detection did not work there. The
casing path no longer depends on collation data, so they can run.
The previous commit routed IcuChangeCase through GlobalizationNative_*
ChangeCaseTurkish for tr/az locales by deriving the language from the
locale name. The follow-up CI run on Android still failed with the same
8 tests returning U+0069/U+0049 instead of U+0131/U+0130, which means
IcuChangeCase was never reached at all on that platform.
The culprit is PopulateIsAsciiCasingSameAsInvariant in TextInfo.cs: the
same CompareInfo.Compare probe that NeedsTurkishCasing used to depend on
is also used here to detect whether ASCII casing matches the invariant
culture. On Android, where the system ICU's collation data is
incomplete, the probe incorrectly returns True for tr-TR, so
ChangeCaseCommon's ASCII fast path (line 262 / 321) calls
Ascii.ToLower/ToUpper, which hardcodes the U+0049 -> U+0069 mapping and
never invokes ChangeCaseCore. That matches the failure symptom exactly.
Mirror the locale-name derivation already used by NeedsTurkishCasing:
short-circuit tr/az to False directly in the populator, and only fall
through to the CompareInfo probe for other cultures. Also drop the test
guards for Android and LinuxBionic (already cherry-picked as a separate
commit), so the regression is now exercised in CI on all platforms.
@MsfPablo
MsfPabloforce-pushed the turkish-casing-106560 branch from 8979933 to 7a2a403CompareAugust 11, 2026 22:02
@MsfPablo

Copy link
Copy Markdown
Author

Confirmed the hypothesis from point (1) in my last comment — the failing tests never reached IcuChangeCase on Android. The root cause is a separate, second location that also depends on a CompareInfo.Compare probe: PopulateIsAsciiCasingSameAsInvariant in src/libraries/System.Private.CoreLib/src/System/Globalization/TextInfo.cs.

The mechanism. Both overloads of ChangeCaseCommon<TConversion> start with:

if (instance == null || instance.IsAsciiCasingSameAsInvariant)
{
Ascii.ToUpper(source, destination, out charsConsumed); // or ToLower
return; // on success
}

The IsAsciiCasingSameAsInvariant probe runs CompareInfo.Compare("a-z", "A-Z", IgnoreCase) == 0 against the locale's collation. On Android, where the system ICU's collation data is incomplete (the same gap NeedsTurkishCasing was probing around), that comparison incorrectly returns True for tr-TR. The ASCII fast path then takes over and calls Ascii.ToLower('I'), which is hardcoded to U+0069. ChangeCaseCore and therefore IcuChangeCase are never reached, which is why the original NeedsTurkishCasing fix looked correct but had no effect on the test actuals.

The fix (commit 7a2a403) mirrors the locale-name derivation already used by NeedsTurkishCasing: short-circuit tr/az to False in the populator before falling through to the unreliable CompareInfo.Compare probe. The two paths now agree by construction, and either fix alone would still leave the bug present.

Also pushed (6d3b84d): drop the IsAndroid/IsLinuxBionic test guards on the affected ToLower/ToUpper rows so this regression is exercised in CI on every platform going forward.

Sorry it took a follow-up commit rather than getting it right the first time — I should have traced the whole ChangeCaseCommon path before opening, not just IcuChangeCase.

@matouskozak

Copy link
Copy Markdown
Member

@MsfPablo were you able to verify the fix locally? We run thousands of tests on Android CI and as such iterating on a solution using just the CI is inefficient. We have instructions on how to build runtime for Android in https://github.com/dotnet/runtime/blob/main/docs/workflow/building/coreclr/android.md.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.Globalizationcommunity-contributionIndicates that the PR has been added by a community memberos-android

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android - With set Turkish locale "i".ToUpper() is giving 'I" instead of 'İ'

4 participants

@MsfPablo@tarekgh@matouskozak@ilonatommy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Determine Turkish casing from the locale name instead of collation - #132002

Open
MsfPablo wants to merge 3 commits into
dotnet:mainfrom
MsfPablo:turkish-casing-106560
Open

Determine Turkish casing from the locale name instead of collation#132002
MsfPablo wants to merge 3 commits into
dotnet:mainfrom
MsfPablo:turkish-casing-106560

Conversation

@MsfPablo

Copy link
Copy Markdown

Fixes#106560

Problem

On Android, "i".ToUpper(new CultureInfo("tr-TR")) returns I (U+0049) instead of İ (U+0130), and "I".ToLower(...) returns i instead of ı. The same code is correct on Windows/Linux/macOS.

Cause

TextInfo.NeedsTurkishCasing does not look at the locale at all — it infers the need for Turkish casing by probing the collation tailoring:

returnCultureInfo.GetCultureInfo(localeName).CompareInfo.Compare("ı","I",CompareOptions.IgnoreCase)==0;

Android uses the system ICU, which does not carry the tr/az collation tailoring that makes U+0131 compare equal to I under IgnoreCase (the same underlying gap tracked by #60568). The probe therefore returns false for tr-TR, IcuChangeCase takes the ChangeCase path instead of ChangeCaseTurkish, and the dotted/dotless i mapping is silently lost. GlobalizationNative_ChangeCaseTurkish itself is fine and is already built for Android — it is simply never called.

Fix

Decide from the locale's language subtag instead, which is what ICU itself does (ustrcase_getCaseLocale selects UCASE_LOC_TURKISH for languages tr and az). This removes the dependency on collation data and makes the behavior identical on every ICU platform.

This should be behavior-preserving elsewhere: tr and az are exactly the languages whose collation tailoring made the old probe return true, and the root collation does not equate U+0131 with I. The existing en-US-POSIX regression case — which exists precisely because the old check was collation-based — still passes, since its language subtag is en.

Tests

The Turkish ToUpper/ToLower cases in TextInfoTests were skipped on Android/LinuxBionic with the comment "Android has its own ICU, which doesn't work well with tr". Those guards are removed so the cases now run on those platforms and cover this fix. They exercise tr, tr-TR, az and az-Latn-AZ.

Verification

I want to be upfront: I have not built or run this locally. Building dotnet/runtime for Android and running the globalization suite on a device/emulator was not something I could do in my environment, so I am relying on CI (including the Android legs) to validate it. The change is small and self-contained, and the re-enabled test cases are the intended proof. Happy to iterate if CI shows anything unexpected — in particular if some locale I have not considered relied on the old collation-derived answer.

cc @matouskozak@tarekgh@ilonatommy

@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Aug 7, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'arch-android': @vitek-karas, @simonrozsival, @steveisok, @akoeplinger
See info in area-owners.md if you want to be subscribed.

@tarekghtarekgh added this to the 11.0.0 milestone Aug 7, 2026
@tarekgh

Copy link
Copy Markdown
Member

/azp run runtime-extra-platforms

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@matouskozak

matouskozak commented Aug 10, 2026

Copy link
Copy Markdown
Member

Thank you @MsfPablo for looking into this. Not sure if you're able to acces the CI results at: https://dev.azure.com/dnceng-public/public/_build/results?buildId=1544675&view=logs&jobId=c9e87f9f-b882-5f07-e8c2-f8cdb4f422a2&j=c9e87f9f-b882-5f07-e8c2-f8cdb4f422a2&t=f555a9c0-ef97-5d93-0768-acf0622a0bcb

If not, here is the extract of the failing tests:

 08-07 14:55:39.765 8433 8449 I DOTNET : System.Globalization.Tests.OrdinalCasingTests 0.2236177000000000111206515148 ms
08-07 14:55:39.775 8433 8449 I DOTNET : Failed tests:
08-07 14:55:39.776 8433 8449 I DOTNET : 1) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "I", expected: "ı") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "ı"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "i"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0052859999999999999001354389
08-07 14:55:39.776 8433 8449 I DOTNET : 2) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "HI!", expected: "hı!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0001764000000000000051465776
08-07 14:55:39.776 8433 8449 I DOTNET : 3) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr", str: "HI\n\0Hİ\t!", expected: "hı\n\0hi\t!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı\n\0hi\t!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi\n\0hi\t!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0001054000000000000005244069
08-07 14:55:39.776 8433 8449 I DOTNET : 4) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "I", expected: "ı") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "ı"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "i"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.776 8433 8449 I DOTNET : Execution time: 0.0020343000000000001706301767
08-07 14:55:39.776 8433 8449 I DOTNET : 5) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "HI!", expected: "hı!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.776 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.776 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.776 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.776 8433 8449 I DOTNET : Expected: "hı!"
08-07 14:55:39.776 8433 8449 I DOTNET : Actual: "hi!"
08-07 14:55:39.776 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.776 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000762999999999999979237095
08-07 14:55:39.777 8433 8449 I DOTNET : 6) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToLower Test name: System.Globalization.Tests.TextInfoMiscTests.ToLower(name: "tr-TR", str: "HI\n\0Hİ\t!", expected: "hı\n\0hi\t!") Test case: System.Globalization.Tests.TextInfoMiscTests.ToLower
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 1)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "hı\n\0hi\t!"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "hi\n\0hi\t!"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 1) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToLower(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToLower(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000733999999999999951962038
08-07 14:55:39.777 8433 8449 I DOTNET : 7) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToUpper Test name: System.Globalization.Tests.TextInfoMiscTests.ToUpper(name: "tr", str: "i", expected: "İ") Test case: System.Globalization.Tests.TextInfoMiscTests.ToUpper
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "İ"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "I"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToUpper(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0001252000000000000074634743
08-07 14:55:39.777 8433 8449 I DOTNET : 8) [FAIL] System.Globalization.Tests.TextInfoMiscTests.ToUpper Test name: System.Globalization.Tests.TextInfoMiscTests.ToUpper(name: "tr-TR", str: "i", expected: "İ") Test case: System.Globalization.Tests.TextInfoMiscTests.ToUpper
08-07 14:55:39.777 8433 8449 I DOTNET : Assembly: [System.Globalization.Tests, Version=11.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]
08-07 14:55:39.777 8433 8449 I DOTNET : Exception messages: Assert.Equal() Failure: Strings differ
08-07 14:55:39.777 8433 8449 I DOTNET : ↓ (pos 0)
08-07 14:55:39.777 8433 8449 I DOTNET : Expected: "İ"
08-07 14:55:39.777 8433 8449 I DOTNET : Actual: "I"
08-07 14:55:39.777 8433 8449 I DOTNET : ↑ (pos 0) Exception stack traces: at System.Globalization.Tests.TextInfoMiscTests.TestToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Globalization.Tests.TextInfoMiscTests.ToUpper(String name, String str, String expected)
08-07 14:55:39.777 8433 8449 I DOTNET : at InvokeStub_TextInfoMiscTests.ToUpper(Object, Span`1)
08-07 14:55:39.777 8433 8449 I DOTNET : at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
08-07 14:55:39.777 8433 8449 I DOTNET : Execution time: 0.0000765000000000000027850985

@MsfPablo

Copy link
Copy Markdown
Author

@dotnet-policy-service agree


Thanks @matouskozak for the CI extract — I can see the same 8 failures land on Android, and the symptoms (Expected: ı / Actual: i, and ditto for U+0130 / U+0049) are exactly the bug the probe was supposed to flush out. The patch's NeedsTurkishCasing should now return true for tr / tr-TR / az/az-Latn-AZ and route IcuChangeCase through GlobalizationNative_ChangeCaseTurkish, which is unconditionally compiled in pal_casing.c (no __ANDROID__ guard around it). The native Turkish mapping itself is correct (U+0049 → U+0131 lower, U+0069 → U+0130 upper).

A few things I want to nail down before I push a follow-up commit, ideally with @tarekgh's CI results replicated locally:

  1. Where the path actually breaks. I want to check whether the failing tests reach IcuChangeCase at all — IsAsciiCasingSameAsInvariant for tr-TR is itself populated via a CompareInfo.Compare probe on line 582 of TextInfo.cs, which has the same Android-collation gap. If that probe ends up True on Android, ChangeCaseCommon takes the ASCII fast path and never calls IcuChangeCase. That's plausible and would explain the actuals — Ascii.ToLower('I') is hardcoded to U+0069.
  2. Build artefacts. Could you confirm that the Android job re-pulled/recompiled System.Private.CoreLib and System.Globalization.Native for this PR? The probe change is in C# but the dispatch is in the native PAL, and a stale AOT snapshot on the device would explain the symptoms exactly.
  3. LinuxBionic. Same tests are skipped on LinuxBionic in the old code with the same comment. Is LinuxBionic also failing, or only Android? If only Android, the native PAL is likely fine and the issue is in the managed dispatch on that platform specifically.

I'll re-examine the IsAsciiCasingSameAsInvariant path tonight and push a follow-up commit that either also derives from the locale name (consistent with the NeedsTurkishCasing decision) or short-circuits tr/az to False directly. If point 2 turns out to be the cause, that's a no-op fix and I'll say so.

Sorry for the CI thrash — I should have at least traced the whole ChangeCaseCommon path before opening, not just IcuChangeCase.

TextInfo.NeedsTurkishCasing detected the Turkish dotted/dotless i casing
rules by probing whether the culture collates U+0131 equal to 'I' under
IgnoreCase. On Android the system ICU does not provide the collation data
that probe depends on, so the check returned false for tr-TR and casing
silently fell back to the non-Turkish path, making i.ToUpper() yield
'I' instead of U+0130.
Fixesdotnet#106560
These ToLower/ToUpper cases were skipped on Android and LinuxBionic
because the collation-based Turkish detection did not work there. The
casing path no longer depends on collation data, so they can run.
The previous commit routed IcuChangeCase through GlobalizationNative_*
ChangeCaseTurkish for tr/az locales by deriving the language from the
locale name. The follow-up CI run on Android still failed with the same
8 tests returning U+0069/U+0049 instead of U+0131/U+0130, which means
IcuChangeCase was never reached at all on that platform.
The culprit is PopulateIsAsciiCasingSameAsInvariant in TextInfo.cs: the
same CompareInfo.Compare probe that NeedsTurkishCasing used to depend on
is also used here to detect whether ASCII casing matches the invariant
culture. On Android, where the system ICU's collation data is
incomplete, the probe incorrectly returns True for tr-TR, so
ChangeCaseCommon's ASCII fast path (line 262 / 321) calls
Ascii.ToLower/ToUpper, which hardcodes the U+0049 -> U+0069 mapping and
never invokes ChangeCaseCore. That matches the failure symptom exactly.
Mirror the locale-name derivation already used by NeedsTurkishCasing:
short-circuit tr/az to False directly in the populator, and only fall
through to the CompareInfo probe for other cultures. Also drop the test
guards for Android and LinuxBionic (already cherry-picked as a separate
commit), so the regression is now exercised in CI on all platforms.
@MsfPablo
MsfPabloforce-pushed the turkish-casing-106560 branch from 8979933 to 7a2a403CompareAugust 11, 2026 22:02
@MsfPablo

Copy link
Copy Markdown
Author

Confirmed the hypothesis from point (1) in my last comment — the failing tests never reached IcuChangeCase on Android. The root cause is a separate, second location that also depends on a CompareInfo.Compare probe: PopulateIsAsciiCasingSameAsInvariant in src/libraries/System.Private.CoreLib/src/System/Globalization/TextInfo.cs.

The mechanism. Both overloads of ChangeCaseCommon<TConversion> start with:

if (instance == null || instance.IsAsciiCasingSameAsInvariant)
{
Ascii.ToUpper(source, destination, out charsConsumed); // or ToLower
return; // on success
}

The IsAsciiCasingSameAsInvariant probe runs CompareInfo.Compare("a-z", "A-Z", IgnoreCase) == 0 against the locale's collation. On Android, where the system ICU's collation data is incomplete (the same gap NeedsTurkishCasing was probing around), that comparison incorrectly returns True for tr-TR. The ASCII fast path then takes over and calls Ascii.ToLower('I'), which is hardcoded to U+0069. ChangeCaseCore and therefore IcuChangeCase are never reached, which is why the original NeedsTurkishCasing fix looked correct but had no effect on the test actuals.

The fix (commit 7a2a403) mirrors the locale-name derivation already used by NeedsTurkishCasing: short-circuit tr/az to False in the populator before falling through to the unreliable CompareInfo.Compare probe. The two paths now agree by construction, and either fix alone would still leave the bug present.

Also pushed (6d3b84d): drop the IsAndroid/IsLinuxBionic test guards on the affected ToLower/ToUpper rows so this regression is exercised in CI on every platform going forward.

Sorry it took a follow-up commit rather than getting it right the first time — I should have traced the whole ChangeCaseCommon path before opening, not just IcuChangeCase.

@matouskozak

Copy link
Copy Markdown
Member

@MsfPablo were you able to verify the fix locally? We run thousands of tests on Android CI and as such iterating on a solution using just the CI is inefficient. We have instructions on how to build runtime for Android in https://github.com/dotnet/runtime/blob/main/docs/workflow/building/coreclr/android.md.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.Globalizationcommunity-contributionIndicates that the PR has been added by a community memberos-android

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android - With set Turkish locale "i".ToUpper() is giving 'I" instead of 'İ'

4 participants

@MsfPablo@tarekgh@matouskozak@ilonatommy