diff --git a/eng/pipelines/arcade/setup-test-env.yml b/eng/pipelines/arcade/setup-test-env.yml index 2941477fb0b7..634e1d4579fa 100644 --- a/eng/pipelines/arcade/setup-test-env.yml +++ b/eng/pipelines/arcade/setup-test-env.yml @@ -12,6 +12,8 @@ steps: fetchDepth: 1 clean: true +- template: /eng/pipelines/common/enable-kvm.yml@self + - template: /eng/pipelines/common/provision.yml@self parameters: checkoutDirectory: '$(System.DefaultWorkingDirectory)' diff --git a/eng/pipelines/ci.yml b/eng/pipelines/ci.yml index ef9fd452abd8..58b1ba5dbb67 100644 --- a/eng/pipelines/ci.yml +++ b/eng/pipelines/ci.yml @@ -108,6 +108,13 @@ parameters: - ImageOverride -equals ACES_VM_SharedPool_Tahoe label: macOS +- name: AndroidPoolLinux + type: object + default: + name: MAUI-DNCENG + demands: + - ImageOverride -equals 1ESPT-Ubuntu22.04 + # Condition for MacOSPool comparison lanes (non-ARM64) # Runs on: (non-PR on main/net*.0/release/*/inflight/*) OR (PR targeting net*.0/release/*/inflight/*) @@ -277,12 +284,9 @@ stages: # TODO: macOSTemplates and AOT template categories - name: mac_runandroid_tests ${{ if eq(variables['Build.DefinitionName'], 'maui-pr') }}: - pool: - name: AcesShared - demands: - - ImageOverride -equals ACES_arm64_Sequoia_Xcode + pool: ${{ parameters.AndroidPoolLinux }} ${{ else }}: - pool: ${{ parameters.MacOSPool.internal }} + pool: ${{ parameters.AndroidPoolLinux }} timeout: 240 testCategory: RunOnAndroid diff --git a/eng/pipelines/common/device-tests-steps.yml b/eng/pipelines/common/device-tests-steps.yml index 8a59c42d73a5..46a7f09f147c 100644 --- a/eng/pipelines/common/device-tests-steps.yml +++ b/eng/pipelines/common/device-tests-steps.yml @@ -35,14 +35,8 @@ steps: continueOnError: true timeoutInMinutes: 60 -# Enable KVM for Android builds on Linux - ${{ if and(ne(parameters.buildType, 'buildOnly'), eq(parameters.platform, 'android')) }}: - - bash: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - displayName: Enable KVM - condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + - template: enable-kvm.yml # Provision the various SDKs that are needed - template: provision.yml diff --git a/eng/pipelines/common/enable-kvm.yml b/eng/pipelines/common/enable-kvm.yml new file mode 100644 index 000000000000..9bb2050cc14c --- /dev/null +++ b/eng/pipelines/common/enable-kvm.yml @@ -0,0 +1,8 @@ +# Enable KVM for Android tests on Linux +steps: +- bash: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + displayName: Enable KVM + condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) diff --git a/eng/pipelines/common/ui-tests-steps.yml b/eng/pipelines/common/ui-tests-steps.yml index be2d650f4711..e3c1186e329c 100644 --- a/eng/pipelines/common/ui-tests-steps.yml +++ b/eng/pipelines/common/ui-tests-steps.yml @@ -50,14 +50,8 @@ steps: continueOnError: true timeoutInMinutes: 60 -# Enable KVM for Android builds on Linux - ${{ if and(ne(parameters.buildType, 'buildOnly'), eq(parameters.platform, 'android')) }}: - - bash: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - displayName: Enable KVM - condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + - template: enable-kvm.yml - ${{ if eq(parameters.platform, 'catalyst')}}: - bash: | diff --git a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs index eb8e5dd7a673..76319bab55a2 100644 --- a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs +++ b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.ComponentModel; using Android.Content; @@ -24,6 +25,10 @@ internal static class ToolbarExtensions { static ColorStateList? _defaultTitleTextColor; static int? _defaultNavigationIconColor; + + // Track which ToolbarItem should currently be associated with each MenuItem ID to prevent race conditions + // This prevents stale async icon loading callbacks from updating the wrong toolbar items during navigation + static readonly ConcurrentDictionary> _menuItemToolbarItemMap = new(); public static void UpdateIsVisible(this AToolbar nativeToolbar, Toolbar toolbar) { @@ -242,6 +247,9 @@ public static void UpdateMenuItems(this AToolbar toolbar, var previousMenuItem = previousMenuItems[j]; if (menu.FindItem(previousMenuItem.ItemId) == null) { + // Clean up the mapping for disposed MenuItems + _menuItemToolbarItemMap.TryRemove(previousMenuItem.ItemId, out _); + previousMenuItem.Dispose(); previousMenuItems.RemoveAt(j); } @@ -261,8 +269,13 @@ public static void UpdateMenuItems(this AToolbar toolbar, int toolBarItemCount = i; while (toolBarItemCount < previousMenuItems.Count) { - menu?.RemoveItem(previousMenuItems[toolBarItemCount].ItemId); - previousMenuItems[toolBarItemCount].Dispose(); + var menuItemToRemove = previousMenuItems[toolBarItemCount]; + menu?.RemoveItem(menuItemToRemove.ItemId); + + // Clean up the mapping for disposed MenuItems + _menuItemToolbarItemMap.TryRemove(menuItemToRemove.ItemId, out _); + + menuItemToRemove.Dispose(); previousMenuItems.RemoveAt(toolBarItemCount); } @@ -335,6 +348,12 @@ static void UpdateMenuItem(AToolbar toolbar, menuitem.SetEnabled(item.IsEnabled); menuitem.SetTitleOrContentDescription(item); + // Track which ToolbarItem should be associated with this MenuItem to prevent race conditions + _menuItemToolbarItemMap[menuitem.ItemId] = new WeakReference(item); + + // NOTE: Custom updateMenuItemIcon callbacks are responsible for their own + // race condition handling. The _menuItemToolbarItemMap guard only applies + // to the default UpdateMenuItemIcon path. if (updateMenuItemIcon != null) updateMenuItemIcon(context, menuitem, item); else @@ -370,6 +389,13 @@ internal static void UpdateMenuItemIcon(this IMauiContext mauiContext, IMenuItem return; } + if (!_menuItemToolbarItemMap.TryGetValue(menuItem.ItemId, out var weakRef) + || !weakRef.TryGetTarget(out var currentToolbarItem) + || !ReferenceEquals(currentToolbarItem, toolBarItem)) + { + return; + } + if (baseDrawable != null) { using (var constant = baseDrawable.GetConstantState()) diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/EntryClearButtonShouldBeVisibleOnDarkTheme.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/EntryClearButtonShouldBeVisibleOnDarkTheme.png new file mode 100644 index 000000000000..6d5158999702 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/EntryClearButtonShouldBeVisibleOnDarkTheme.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/EntryClearButtonShouldBeVisibleOnLightTheme.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/EntryClearButtonShouldBeVisibleOnLightTheme.png new file mode 100644 index 000000000000..fe4be74dc09a Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/EntryClearButtonShouldBeVisibleOnLightTheme.png differ diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue32886.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue32886.cs new file mode 100644 index 000000000000..b9f5498ed4d3 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue32886.cs @@ -0,0 +1,45 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 32886, "[Android, iOS, Mac] Entry ClearButton not visible on dark theme", PlatformAffected.Android | PlatformAffected.iOS | PlatformAffected.macOS)] +public class Issue32886 : TestContentPage +{ + protected override void Init() + { + Title = "Issue32886"; + + // Create the UITestEntry with ClearButtonVisibility + var entry = new UITestEntry + { + Text = "Entry Text", + IsCursorVisible = false, + IsSpellCheckEnabled = false, + IsTextPredictionEnabled = false, + AutomationId = "TestEntry", + ClearButtonVisibility = ClearButtonVisibility.WhileEditing + }; + + var button = new Button + { + Text = "Change theme", + AutomationId = "ThemeButton" + }; + button.Clicked += Button_Clicked; + + var layout = new VerticalStackLayout(); + layout.Children.Add(entry); + layout.Children.Add(button); + + Content = layout; + + // Set background color based on app theme + this.SetAppThemeColor(BackgroundColorProperty, Colors.White, Colors.Black); + } + + private void Button_Clicked(object sender, EventArgs e) + { + if (Application.Current is not null) + { + Application.Current.UserAppTheme = Application.Current.UserAppTheme != AppTheme.Dark ? AppTheme.Dark : AppTheme.Light; + } + } +} \ No newline at end of file diff --git a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/EntryClearButtonShouldBeVisibleOnDarkTheme.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/EntryClearButtonShouldBeVisibleOnDarkTheme.png new file mode 100644 index 000000000000..12e0dfe930eb Binary files /dev/null and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/EntryClearButtonShouldBeVisibleOnDarkTheme.png differ diff --git a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/EntryClearButtonShouldBeVisibleOnLightTheme.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/EntryClearButtonShouldBeVisibleOnLightTheme.png new file mode 100644 index 000000000000..56990c3225f6 Binary files /dev/null and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/EntryClearButtonShouldBeVisibleOnLightTheme.png differ diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32886.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32886.cs new file mode 100644 index 000000000000..9cadff37384f --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32886.cs @@ -0,0 +1,55 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue32886 : _IssuesUITest +{ + public Issue32886(TestDevice device) : base(device) + { + } + + public override string Issue => "[Android, iOS, Mac] Entry ClearButton not visible on dark theme"; + + [Test, Order(1)] + [Category(UITestCategories.Entry)] + public void EntryClearButtonShouldBeVisibleOnLightTheme() + { + App.WaitForElement("TestEntry"); + App.Tap("TestEntry"); +#if ANDROID // On Android, to address CI flakiness, the keyboard is dismissed. + if (App.WaitForKeyboardToShow(timeout: TimeSpan.FromSeconds(1))) + { + App.DismissKeyboard(); + } +#endif + +#if IOS + // On iOS, the virtual keyboard appears inconsistent with keyboard characters casing, can cause flaky test results. As this test verifying only the entry clear button color, crop the bottom portion of the screenshot to exclude the keyboard. + // Using DismissKeyboard() would unfocus the control in iOS, so we're using cropping instead to maintain focus during testing. + VerifyScreenshot(cropBottom: 1550); +#else + VerifyScreenshot(); +#endif + } + + [Test, Order(2)] + [Category(UITestCategories.Entry)] + public void EntryClearButtonShouldBeVisibleOnDarkTheme() + { + App.WaitForElement("TestEntry"); + App.Tap("ThemeButton"); +#if WINDOWS // On Windows, the clear button isn't visible when Entry loses focus, so manually focused to check its icon color. + App.Tap("TestEntry"); +#endif + +#if IOS + // On iOS, the virtual keyboard appears inconsistent with keyboard characters casing, can cause flaky test results. As this test verifying only the entry clear button color, crop the bottom portion of the screenshot to exclude the keyboard. + // Using DismissKeyboard() would unfocus the control in iOS, so we're using cropping instead to maintain focus during testing. + VerifyScreenshot(cropBottom: 1550); +#else + VerifyScreenshot(); +#endif + } +} \ No newline at end of file diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EntryClearButtonShouldBeVisibleOnDarkTheme.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EntryClearButtonShouldBeVisibleOnDarkTheme.png new file mode 100644 index 000000000000..2e31c8a77b08 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EntryClearButtonShouldBeVisibleOnDarkTheme.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EntryClearButtonShouldBeVisibleOnLightTheme.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EntryClearButtonShouldBeVisibleOnLightTheme.png new file mode 100644 index 000000000000..b971b924bc2c Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EntryClearButtonShouldBeVisibleOnLightTheme.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/EntryClearButtonShouldBeVisibleOnDarkTheme.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/EntryClearButtonShouldBeVisibleOnDarkTheme.png new file mode 100644 index 000000000000..a52d3ac13dcb Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/EntryClearButtonShouldBeVisibleOnDarkTheme.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/EntryClearButtonShouldBeVisibleOnLightTheme.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/EntryClearButtonShouldBeVisibleOnLightTheme.png new file mode 100644 index 000000000000..545e17b916f8 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/EntryClearButtonShouldBeVisibleOnLightTheme.png differ diff --git a/src/Core/src/Platform/Android/EditTextExtensions.cs b/src/Core/src/Platform/Android/EditTextExtensions.cs index bbb99c029ab4..293571ac8043 100644 --- a/src/Core/src/Platform/Android/EditTextExtensions.cs +++ b/src/Core/src/Platform/Android/EditTextExtensions.cs @@ -231,7 +231,23 @@ internal static void UpdateClearButtonColor(this EditText editText, Graphics.Col } else { - clearButtonDrawable?.ClearColorFilter(); + if (OperatingSystem.IsAndroidVersionAtLeast(23) && editText.Context?.Theme is Resources.Theme theme) + { + using var ta = theme.ObtainStyledAttributes([global::Android.Resource.Attribute.TextColorPrimary]); + var cs = ta.GetColorStateList(0); + + if (cs is not null) + { + // Clear button is only visible when enabled, so just use the enabled state + int[] enabledState = [global::Android.Resource.Attribute.StateEnabled]; + var color = new global::Android.Graphics.Color(cs.GetColorForState(enabledState, Colors.Black.ToPlatform())); + clearButtonDrawable?.SetColorFilter(color, FilterMode.SrcIn); + } + } + else + { + clearButtonDrawable?.ClearColorFilter(); + } } } diff --git a/src/Core/src/Platform/iOS/TextFieldExtensions.cs b/src/Core/src/Platform/iOS/TextFieldExtensions.cs index 7a501d0d8f83..880bbdf886c1 100644 --- a/src/Core/src/Platform/iOS/TextFieldExtensions.cs +++ b/src/Core/src/Platform/iOS/TextFieldExtensions.cs @@ -215,8 +215,8 @@ internal static void UpdateClearButtonColor(this UITextField textField, IEntry e if (entry.TextColor is null) { - clearButton.SetImage(defaultClearImage, UIControlState.Normal); - clearButton.SetImage(defaultClearImage, UIControlState.Highlighted); + // Setting TintColor to null allows the system to automatically apply the appropriate color based on the current theme (light or dark mode) + clearButton.TintColor = null; } else { diff --git a/src/DotNet/DotNet.csproj b/src/DotNet/DotNet.csproj index 5cab15a090ed..4e4e92feec35 100644 --- a/src/DotNet/DotNet.csproj +++ b/src/DotNet/DotNet.csproj @@ -78,7 +78,9 @@ <_WorkloadSource Include="$(NugetArtifactsPath)" /> - <_LocalWorkloadIds Include="maui" /> + + <_LocalWorkloadIds Include="maui-android" Condition="$([MSBuild]::IsOSPlatform('linux'))" /> + <_LocalWorkloadIds Include="maui" Condition="!$([MSBuild]::IsOSPlatform('linux'))" /> <_LocalWorkloadIds Include="tizen" Condition=" '$(IncludeTizenTargetFrameworks)' == 'true' " /> diff --git a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/AndroidTemplateTests.cs b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/AndroidTemplateTests.cs index 2e8027bcfdfd..a4115a3f182d 100644 --- a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/AndroidTemplateTests.cs +++ b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/AndroidTemplateTests.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Runtime.InteropServices; +using System.Text.RegularExpressions; using Microsoft.Maui.IntegrationTests.Android; namespace Microsoft.Maui.IntegrationTests @@ -94,6 +95,12 @@ public void RunOnAndroid(string id, string framework, string config, string? tri Assert.True(DotnetInternal.New(id, projectDir, framework, output: _output), $"Unable to create template {id}. Check test output for errors."); + // On Linux, only the maui-android workload is installed. Previous .NET + // templates may still include iOS/macOS TFMs causing NETSDK1178 errors + // during restore. Strip them so only Android remains. + if (TestEnvironment.IsLinux) + StripNonAndroidTfms(projectFile, framework); + var buildProps = BuildProps; if (!string.IsNullOrEmpty(trimMode)) { @@ -128,5 +135,20 @@ void AddInstrumentation(string projectDir) "MainLauncher = true, Name = \"com.microsoft.mauitemplate.MainActivity\""); } + static void StripNonAndroidTfms(string projectFile, string framework) + { + var content = File.ReadAllText(projectFile); + var androidTfm = $"{framework}-android"; + // Remove conditional TargetFrameworks lines (iOS/macOS/Windows additions) + content = Regex.Replace(content, + @"\s*[^<]*", + ""); + // Set the base TargetFrameworks to Android only + content = Regex.Replace(content, + @"[^<]*", + $"{androidTfm}"); + File.WriteAllText(projectFile, content); + } + } }