diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue33070.xaml b/src/Controls/tests/TestCases.HostApp/Issues/Issue33070.xaml
new file mode 100644
index 000000000000..4d433d443913
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue33070.xaml
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue33070.xaml.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue33070.xaml.cs
new file mode 100644
index 000000000000..775e3b3c2747
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue33070.xaml.cs
@@ -0,0 +1,137 @@
+using System;
+using Microsoft.Maui.Controls;
+using Microsoft.Maui.Graphics;
+
+namespace Maui.Controls.Sample.Issues;
+
+[Issue(IssueTracker.Github, 33070, "Fix Android drawable mutation crash", PlatformAffected.Android)]
+public partial class Issue33070 : ContentPage
+{
+ private readonly Color[] _testColors = new[]
+ {
+ Colors.Red, Colors.Blue, Colors.Green, Colors.Purple,
+ Colors.Orange, Colors.Pink, Colors.Cyan, Colors.Yellow
+ };
+
+ private int _colorIndex = 0;
+ private int _rapidChangeCounter = 0;
+
+ public Issue33070()
+ {
+ InitializeComponent();
+ }
+
+ private void OnChangeActivityIndicatorColor(object sender, EventArgs e)
+ {
+ try
+ {
+ _colorIndex = (_colorIndex + 1) % _testColors.Length;
+ var newColor = _testColors[_colorIndex];
+ ActivityIndicatorTest.Color = newColor;
+ ActivityIndicatorStatus.Text = $"Color changed to {newColor}";
+ }
+ catch (Exception ex)
+ {
+ ActivityIndicatorStatus.Text = $"ERROR: {ex.Message}";
+ }
+ }
+
+ private void OnChangeEntryColor(object sender, EventArgs e)
+ {
+ try
+ {
+ _colorIndex = (_colorIndex + 1) % _testColors.Length;
+ var newColor = _testColors[_colorIndex];
+ EntryTest.TextColor = newColor;
+ EntryStatus.Text = $"Color changed to {newColor}";
+ }
+ catch (Exception ex)
+ {
+ EntryStatus.Text = $"ERROR: {ex.Message}";
+ }
+ }
+
+ private void OnChangeSwitchColors(object sender, EventArgs e)
+ {
+ try
+ {
+ _colorIndex = (_colorIndex + 1) % _testColors.Length;
+ var newThumbColor = _testColors[_colorIndex];
+ var newTrackColor = _testColors[(_colorIndex + 1) % _testColors.Length];
+
+ SwitchTest.ThumbColor = newThumbColor;
+ SwitchTest.OnColor = newTrackColor;
+
+ SwitchStatus.Text = $"Thumb: {newThumbColor}, Track: {newTrackColor}";
+ }
+ catch (Exception ex)
+ {
+ SwitchStatus.Text = $"ERROR: {ex.Message}";
+ }
+ }
+
+ private void OnChangeSearchBarColors(object sender, EventArgs e)
+ {
+ try
+ {
+ _colorIndex = (_colorIndex + 1) % _testColors.Length;
+ var newTextColor = _testColors[_colorIndex];
+ var newPlaceholderColor = _testColors[(_colorIndex + 1) % _testColors.Length];
+ var newCancelColor = _testColors[(_colorIndex + 2) % _testColors.Length];
+
+ SearchBarTest.TextColor = newTextColor;
+ SearchBarTest.PlaceholderColor = newPlaceholderColor;
+ SearchBarTest.CancelButtonColor = newCancelColor;
+
+ SearchBarStatus.Text = $"Colors changed successfully";
+ }
+ catch (Exception ex)
+ {
+ SearchBarStatus.Text = $"ERROR: {ex.Message}";
+ }
+ }
+
+ private async void OnRunRapidChangesTest(object sender, EventArgs e)
+ {
+ const int totalIterations = 50;
+ _rapidChangeCounter = 0;
+
+ try
+ {
+ RapidChangesStatus.Text = "Running...";
+
+ for (int i = 0; i < totalIterations; i++)
+ {
+ var color1 = _testColors[i % _testColors.Length];
+ var color2 = _testColors[(i + 1) % _testColors.Length];
+ var color3 = _testColors[(i + 2) % _testColors.Length];
+ var color4 = _testColors[(i + 3) % _testColors.Length];
+
+ // Rapidly change all controls
+ ActivityIndicatorTest.Color = color1;
+ EntryTest.TextColor = color2;
+ SwitchTest.ThumbColor = color3;
+ SwitchTest.OnColor = color4;
+ SearchBarTest.TextColor = color1;
+ SearchBarTest.PlaceholderColor = color2;
+ SearchBarTest.CancelButtonColor = color3;
+
+ _rapidChangeCounter++;
+
+ if (i % 10 == 0)
+ {
+ RapidChangesProgress.Text = $"Iteration {i + 1}/{totalIterations}";
+ await Task.Delay(10); // Small delay to allow UI update
+ }
+ }
+
+ RapidChangesStatus.Text = $"✅ Completed {_rapidChangeCounter} iterations";
+ RapidChangesProgress.Text = "No crashes!";
+ }
+ catch (Exception ex)
+ {
+ RapidChangesStatus.Text = $"❌ Failed at iteration {_rapidChangeCounter}";
+ RapidChangesProgress.Text = $"Error: {ex.Message}";
+ }
+ }
+}
diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33070.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33070.cs
new file mode 100644
index 000000000000..f2ac6584bb2f
--- /dev/null
+++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33070.cs
@@ -0,0 +1,119 @@
+using NUnit.Framework;
+using UITest.Appium;
+using UITest.Core;
+
+namespace Microsoft.Maui.TestCases.Tests.Issues;
+
+public class Issue33070 : _IssuesUITest
+{
+ public override string Issue => "Fix Android drawable mutation crash";
+
+ public Issue33070(TestDevice device) : base(device) { }
+
+ [Test]
+ [Category(UITestCategories.ActivityIndicator)]
+ public void ActivityIndicatorColorChangeShouldNotCrash()
+ {
+ App.WaitForElement("ActivityIndicator");
+
+ // Change color multiple times
+ for (int i = 0; i < 5; i++)
+ {
+ App.Tap("ChangeActivityIndicatorButton");
+ App.WaitForElement("ActivityIndicatorStatus");
+
+ var status = App.FindElement("ActivityIndicatorStatus").GetText();
+ Assert.That(status, Does.Contain("Color changed to"), $"Iteration {i + 1}: Color change failed");
+ }
+
+ // Verify no crash occurred
+ var finalStatus = App.FindElement("ActivityIndicatorStatus").GetText();
+ Assert.That(finalStatus, Does.Not.Contain("ERROR"));
+ }
+
+ [Test]
+ [Category(UITestCategories.Entry)]
+ public void EntryTextColorChangeShouldNotCrash()
+ {
+ App.WaitForElement("Entry");
+
+ // Change color multiple times
+ for (int i = 0; i < 5; i++)
+ {
+ App.Tap("ChangeEntryButton");
+ App.WaitForElement("EntryStatus");
+
+ var status = App.FindElement("EntryStatus").GetText();
+ Assert.That(status, Does.Contain("Color changed to"), $"Iteration {i + 1}: Color change failed");
+ }
+
+ // Verify no crash occurred
+ var finalStatus = App.FindElement("EntryStatus").GetText();
+ Assert.That(finalStatus, Does.Not.Contain("ERROR"));
+ }
+
+ [Test]
+ [Category(UITestCategories.Switch)]
+ public void SwitchColorChangeShouldNotCrash()
+ {
+ App.WaitForElement("Switch");
+
+ // Change colors multiple times
+ for (int i = 0; i < 5; i++)
+ {
+ App.Tap("ChangeSwitchButton");
+ App.WaitForElement("SwitchStatus");
+
+ var status = App.FindElement("SwitchStatus").GetText();
+ Assert.That(status, Does.Contain("Thumb:"), $"Iteration {i + 1}: Color change failed");
+ }
+
+ // Verify no crash occurred
+ var finalStatus = App.FindElement("SwitchStatus").GetText();
+ Assert.That(finalStatus, Does.Not.Contain("ERROR"));
+ }
+
+ [Test]
+ [Category(UITestCategories.SearchBar)]
+ public void SearchBarColorChangeShouldNotCrash()
+ {
+ App.WaitForElement("SearchBar");
+
+ // Change colors multiple times
+ for (int i = 0; i < 5; i++)
+ {
+ App.Tap("ChangeSearchBarButton");
+ App.WaitForElement("SearchBarStatus");
+
+ var status = App.FindElement("SearchBarStatus").GetText();
+ Assert.That(status, Does.Contain("Colors changed successfully"), $"Iteration {i + 1}: Color change failed");
+ }
+
+ // Verify no crash occurred
+ var finalStatus = App.FindElement("SearchBarStatus").GetText();
+ Assert.That(finalStatus, Does.Not.Contain("ERROR"));
+ }
+
+ [Test]
+ [Category(UITestCategories.ActivityIndicator)]
+ public void RapidColorChangesShouldNotCrash()
+ {
+ App.WaitForElement("ScrollViewContent");
+ App.ScrollDownTo("RunRapidChangesButton", "ScrollViewContent");
+
+ // Run the stress test
+ App.Tap("RunRapidChangesButton");
+
+ // Wait for completion (50 iterations with small delays)
+ App.WaitForElement("RapidChangesStatus", timeout: TimeSpan.FromSeconds(30));
+
+ // Check that test completed successfully
+ var status = App.FindElement("RapidChangesStatus").GetText();
+ Assert.That(status, Does.Contain("Completed"), "Rapid changes test did not complete");
+ Assert.That(status, Does.Contain("50 iterations"), "Expected 50 iterations");
+
+ // Verify no crashes
+ var progress = App.FindElement("RapidChangesProgress").GetText();
+ Assert.That(progress, Does.Contain("No crashes!"));
+ }
+}
diff --git a/src/Core/src/Platform/Android/ActivityIndicatorExtensions.cs b/src/Core/src/Platform/Android/ActivityIndicatorExtensions.cs
index 9bc649216c45..47075860e499 100644
--- a/src/Core/src/Platform/Android/ActivityIndicatorExtensions.cs
+++ b/src/Core/src/Platform/Android/ActivityIndicatorExtensions.cs
@@ -27,9 +27,9 @@ public static void UpdateColor(this ProgressBar progressBar, IActivityIndicator
var color = activityIndicator.Color;
if (color != null)
- progressBar.IndeterminateDrawable?.SetColorFilter(color.ToPlatform(), FilterMode.SrcIn);
+ progressBar.IndeterminateDrawable = progressBar.IndeterminateDrawable.SafeSetColorFilter(color.ToPlatform(), FilterMode.SrcIn);
else
- progressBar.IndeterminateDrawable?.ClearColorFilter();
+ progressBar.IndeterminateDrawable = progressBar.IndeterminateDrawable.SafeClearColorFilter();
}
}
}
\ No newline at end of file
diff --git a/src/Core/src/Platform/Android/DrawableExtensions.cs b/src/Core/src/Platform/Android/DrawableExtensions.cs
index 8fef451eb1b1..0d138fdb2ef3 100644
--- a/src/Core/src/Platform/Android/DrawableExtensions.cs
+++ b/src/Core/src/Platform/Android/DrawableExtensions.cs
@@ -1,6 +1,7 @@
using System;
using Android.Graphics;
using Android.Graphics.Drawables;
+using Android.Widget;
using AColor = Android.Graphics.Color;
using AColorFilter = Android.Graphics.ColorFilter;
using ADrawable = Android.Graphics.Drawables.Drawable;
@@ -73,5 +74,128 @@ public static void SetColorFilter(this ADrawable drawable, AColor color, FilterM
return null;
}
+
+ // todo make public for net11
+ ///
+ /// Safely applies tint to an ImageView's drawable by mutating it first.
+ /// This prevents crashes when the drawable is shared across multiple views.
+ ///
+ ///
+ /// Android shares Drawable resources for memory efficiency. Modifying a shared
+ /// drawable without calling Mutate() first causes race conditions and crashes.
+ /// See: https://developer.android.com/reference/android/graphics/drawable/Drawable#mutate()
+ ///
+ internal static void SafeSetTint(this ImageView? imageView, Graphics.Color color)
+ {
+ if (imageView?.Drawable is not ADrawable drawable)
+ return;
+
+ var safe = drawable.Mutate();
+ safe?.SetTint(color.ToInt());
+ imageView.SetImageDrawable(safe);
+ }
+
+ ///
+ /// Safely applies tint to an ImageView's drawable by mutating it first.
+ /// This prevents crashes when the drawable is shared across multiple views.
+ ///
+ ///
+ /// Android shares Drawable resources for memory efficiency. Modifying a shared
+ /// drawable without calling Mutate() first causes race conditions and crashes.
+ /// See: https://developer.android.com/reference/android/graphics/drawable/Drawable#mutate()
+ ///
+ internal static void SafeSetTint(this ImageView? imageView, AColor color)
+ {
+ if (imageView?.Drawable is not ADrawable drawable)
+ return;
+
+ var safe = drawable.Mutate();
+ safe?.SetTint(color);
+ imageView.SetImageDrawable(safe);
+ }
+
+ // todo make public for net11
+ ///
+ /// Safely applies tint to a drawable by mutating it first.
+ /// This prevents crashes when the drawable is shared across multiple views.
+ ///
+ ///
+ /// Android shares Drawable resources for memory efficiency. Modifying a shared
+ /// drawable without calling Mutate() first causes race conditions and crashes.
+ /// See: https://developer.android.com/reference/android/graphics/drawable/Drawable#mutate()
+ ///
+ /// The mutated drawable with tint applied, or the original drawable if mutation failed.
+ internal static ADrawable? SafeSetTint(this ADrawable? drawable, Graphics.Color color)
+ {
+ if (drawable is null)
+ return null;
+
+ var safe = drawable.Mutate();
+ safe?.SetTint(color.ToInt());
+ return safe ?? drawable;
+ }
+
+ // todo make public for net11
+ ///
+ /// Safely applies tint to a drawable by mutating it first.
+ /// This prevents crashes when the drawable is shared across multiple views.
+ ///
+ ///
+ /// Android shares Drawable resources for memory efficiency. Modifying a shared
+ /// drawable without calling Mutate() first causes race conditions and crashes.
+ /// See: https://developer.android.com/reference/android/graphics/drawable/Drawable#mutate()
+ ///
+ /// The mutated drawable with tint applied, or the original drawable if mutation failed.
+ internal static ADrawable? SafeSetTint(this ADrawable? drawable, AColor color)
+ {
+ if (drawable is null)
+ return null;
+
+ var safe = drawable.Mutate();
+ safe?.SetTint(color);
+ return safe ?? drawable;
+ }
+
+ // todo make public for net11
+ ///
+ /// Safely applies color filter to a drawable by mutating it first.
+ /// This prevents crashes when the drawable is shared across multiple views.
+ ///
+ ///
+ /// Android shares Drawable resources for memory efficiency. Modifying a shared
+ /// drawable without calling Mutate() first causes race conditions and crashes.
+ /// See: https://developer.android.com/reference/android/graphics/drawable/Drawable#mutate()
+ ///
+ /// The mutated drawable with color filter applied, or the original drawable if mutation failed.
+ internal static ADrawable? SafeSetColorFilter(this ADrawable? drawable, AColor color, FilterMode mode)
+ {
+ if (drawable is null)
+ return null;
+
+ var safe = drawable.Mutate();
+ safe?.SetColorFilter(color, mode);
+ return safe ?? drawable;
+ }
+
+ // todo make public for net11
+ ///
+ /// Safely clears color filter from a drawable by mutating it first.
+ /// This prevents crashes when the drawable is shared across multiple views.
+ ///
+ ///
+ /// Android shares Drawable resources for memory efficiency. Modifying a shared
+ /// drawable without calling Mutate() first causes race conditions and crashes.
+ /// See: https://developer.android.com/reference/android/graphics/drawable/Drawable#mutate()
+ ///
+ /// The mutated drawable with color filter cleared, or the original drawable if mutation failed.
+ internal static ADrawable? SafeClearColorFilter(this ADrawable? drawable)
+ {
+ if (drawable is null)
+ return null;
+
+ var safe = drawable.Mutate();
+ safe?.ClearColorFilter();
+ return safe ?? drawable;
+ }
}
}
\ No newline at end of file
diff --git a/src/Core/src/Platform/Android/EditTextExtensions.cs b/src/Core/src/Platform/Android/EditTextExtensions.cs
index bbb99c029ab4..0dbfe80f3b46 100644
--- a/src/Core/src/Platform/Android/EditTextExtensions.cs
+++ b/src/Core/src/Platform/Android/EditTextExtensions.cs
@@ -227,12 +227,14 @@ internal static void UpdateClearButtonColor(this EditText editText, Graphics.Col
{
if (textColor is not null)
{
- clearButtonDrawable?.SetColorFilter(textColor.ToPlatform(), FilterMode.SrcIn);
+ clearButtonDrawable = clearButtonDrawable.SafeSetColorFilter(textColor.ToPlatform(), FilterMode.SrcIn);
}
else
{
- clearButtonDrawable?.ClearColorFilter();
+ clearButtonDrawable = clearButtonDrawable.SafeClearColorFilter();
}
+
+ editText.SetCompoundDrawablesRelativeWithIntrinsicBounds(null, null, clearButtonDrawable, null);
}
public static void UpdateReturnType(this EditText editText, IEntry entry)
diff --git a/src/Core/src/Platform/Android/SearchViewExtensions.cs b/src/Core/src/Platform/Android/SearchViewExtensions.cs
index 33d786aed442..b4bec5a4539a 100644
--- a/src/Core/src/Platform/Android/SearchViewExtensions.cs
+++ b/src/Core/src/Platform/Android/SearchViewExtensions.cs
@@ -44,7 +44,7 @@ public static void UpdatePlaceholderColor(this SearchView searchView, ISearchBar
editText.SetHintTextColor(color);
var searchMagIconImage = searchView.FindViewById(Resource.Id.search_mag_icon);
- searchMagIconImage?.Drawable?.SetTint(color);
+ searchMagIconImage.SafeSetTint(color);
}
}
@@ -57,7 +57,7 @@ internal static void UpdateTextColor(this SearchView searchView, ITextStyle entr
editText.SetTextColor(color);
var searchMagIconImage = searchView.FindViewById(Resource.Id.search_mag_icon);
- searchMagIconImage?.Drawable?.SetTint(color);
+ searchMagIconImage.SafeSetTint(color);
}
}
@@ -129,14 +129,10 @@ public static void UpdateCancelButtonColor(this SearchView searchView, ISearchBa
if (searchCloseButtonIdentifier > 0)
{
var image = searchView.FindViewById(searchCloseButtonIdentifier);
-
- if (image is not null && image.Drawable is Drawable drawable)
- {
- if (searchBar.CancelButtonColor is not null)
- drawable.SetColorFilter(searchBar.CancelButtonColor, FilterMode.SrcIn);
- else if (TryGetDefaultStateColor(searchView, AAttribute.TextColorPrimary, out var color))
- drawable.SetColorFilter(color, FilterMode.SrcIn);
- }
+ if (searchBar.CancelButtonColor is not null)
+ image.SafeSetTint(searchBar.CancelButtonColor.ToPlatform());
+ else if (TryGetDefaultStateColor(searchView, AAttribute.TextColorPrimary, out var color))
+ image.SafeSetTint(color);
}
}
@@ -154,9 +150,15 @@ internal static void UpdateSearchIconColor(this SearchView searchView, ISearchBa
if (image?.Drawable is not null)
{
if (searchBar.SearchIconColor is not null)
- image.Drawable.SetColorFilter(searchBar.SearchIconColor, FilterMode.SrcIn);
+ {
+ var drawable = image.Drawable.SafeSetColorFilter(searchBar.SearchIconColor.ToPlatform(), FilterMode.SrcIn);
+ image.SetImageDrawable(drawable);
+ }
else
- image.Drawable.ClearColorFilter();
+ {
+ var drawable = image.Drawable.SafeClearColorFilter();
+ image.SetImageDrawable(drawable);
+ }
}
}
}
diff --git a/src/Core/src/Platform/Android/SwitchExtensions.cs b/src/Core/src/Platform/Android/SwitchExtensions.cs
index e551b53aaac6..6bd312ac4022 100644
--- a/src/Core/src/Platform/Android/SwitchExtensions.cs
+++ b/src/Core/src/Platform/Android/SwitchExtensions.cs
@@ -14,11 +14,11 @@ public static void UpdateTrackColor(this ASwitch aSwitch, ISwitch view)
if (trackColor is not null)
{
- aSwitch.TrackDrawable?.SetColorFilter(trackColor, FilterMode.SrcAtop);
+ aSwitch.TrackDrawable = aSwitch.TrackDrawable.SafeSetColorFilter(trackColor.ToPlatform(), FilterMode.SrcAtop);
}
else
{
- aSwitch.TrackDrawable?.ClearColorFilter();
+ aSwitch.TrackDrawable = aSwitch.TrackDrawable.SafeClearColorFilter();
}
}
@@ -28,7 +28,7 @@ public static void UpdateThumbColor(this ASwitch aSwitch, ISwitch view)
if (thumbColor is not null)
{
- aSwitch.ThumbDrawable?.SetColorFilter(thumbColor, FilterMode.SrcAtop);
+ aSwitch.ThumbDrawable = aSwitch.ThumbDrawable.SafeSetColorFilter(thumbColor.ToPlatform(), FilterMode.SrcAtop);
}
}