Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions AIDevGallery.Tests/UnitTests/Samples/ResourceLifecycleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Threading.Tasks;
using Windows.Graphics.Imaging;

namespace AIDevGallery.Tests.UnitTests.Samples;

/// <summary>
/// Tests for BackgroundRemover's SetImageSource method to verify bitmap lifecycle management
/// </summary>
[TestClass]
public class BitmapLifecycleTests
{
[TestMethod]
public void SoftwareBitmapConvertCreatesIndependentCopy()
{
// This test verifies that SoftwareBitmap.Convert creates an independent copy
// and that disposing the original doesn't affect the converted bitmap.
// This is critical for BackgroundRemover's lifecycle management.

// Arrange
var originalBitmap = new SoftwareBitmap(
BitmapPixelFormat.Rgba8,
100,
100,
BitmapAlphaMode.Premultiplied);

// Act
var convertedBitmap = SoftwareBitmap.Convert(
originalBitmap,
BitmapPixelFormat.Bgra8,
BitmapAlphaMode.Premultiplied);

// Assert - Verify it's a new instance with correct properties
Assert.IsNotNull(convertedBitmap, "Converted bitmap should not be null");
Assert.AreNotSame(originalBitmap, convertedBitmap, "Convert should create a new instance");
Assert.AreEqual(originalBitmap.PixelWidth, convertedBitmap.PixelWidth, "Width should match");
Assert.AreEqual(originalBitmap.PixelHeight, convertedBitmap.PixelHeight, "Height should match");
Assert.AreEqual(BitmapPixelFormat.Bgra8, convertedBitmap.BitmapPixelFormat, "Format should be converted");

// Dispose original - this should NOT affect the converted bitmap
originalBitmap.Dispose();

// Assert - converted bitmap should still be usable after disposing original
Assert.AreEqual(100, convertedBitmap.PixelWidth, "Converted bitmap should remain valid");
Assert.AreEqual(100, convertedBitmap.PixelHeight, "Converted bitmap should remain valid");

// Clean up
convertedBitmap.Dispose();
}

[TestMethod]
public async Task BackgroundRemoverBitmapLifecycleVerifyCorrectPattern()
{
// *** CRITICAL TEST FOR BackgroundRemover.xaml.cs ***
// This test validates the exact pattern used in BackgroundRemover:
//
// Pattern in BackgroundRemover.xaml.cs:
// using (var outputBitmap = await ExtractBackground(...))
// {
// await SetImageSource(outputBitmap); // Converts and uses bitmap
// } // outputBitmap is disposed here
//
// Key Question: Is it safe to dispose outputBitmap after SetImageSource?
// Answer: YES, because SoftwareBitmap.Convert creates an independent copy.
//
// This test verifies that the converted bitmap remains valid even after
// the original outputBitmap is disposed, ensuring no use-after-free bugs.

// Arrange - Create a bitmap (simulating ExtractBackground result)
SoftwareBitmap? outputBitmap = null;
SoftwareBitmap? convertedBitmap = null;

try
{
outputBitmap = new SoftwareBitmap(
BitmapPixelFormat.Rgba8,
200,
200,
BitmapAlphaMode.Premultiplied);

// Act - Simulate SetImageSource logic
// Inside SetImageSource, this happens:
convertedBitmap = SoftwareBitmap.Convert(
outputBitmap,
BitmapPixelFormat.Bgra8,
BitmapAlphaMode.Premultiplied);

// At this point in BackgroundRemover, we would:
// await bitmapSource.SetBitmapAsync(convertedBitmap);
// The SetBitmapAsync call completes synchronously with the bitmap data
Assert.IsNotNull(convertedBitmap, "Converted bitmap should be created");
Assert.AreEqual(200, convertedBitmap.PixelWidth, "Initial width should be correct");

// Now simulate the using block exiting - dispose outputBitmap
// This is the CRITICAL moment: does disposing outputBitmap break convertedBitmap?
outputBitmap.Dispose();

// Assert - convertedBitmap should still be valid because it's an independent copy
Assert.AreEqual(200, convertedBitmap.PixelWidth, "Width should still be accessible");
Assert.AreEqual(200, convertedBitmap.PixelHeight, "Height should still be accessible");
Assert.AreEqual(BitmapPixelFormat.Bgra8, convertedBitmap.BitmapPixelFormat, "Format should still be accessible");

await Task.CompletedTask; // Simulate async operation
}
finally
{
// Clean up - in real code, the UI framework holds the convertedBitmap reference
convertedBitmap?.Dispose();
}
}
}
2 changes: 0 additions & 2 deletions AIDevGallery/AIDevGallery.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@
<NoWarn>$(NoWarn);IL2050</NoWarn>
<!-- Microsoft.Xaml.Interactivity.EventTriggerBehavior is not trimmable -->
<NoWarn>$(NoWarn);IL2026</NoWarn>
<!-- TODO: Fix IDisposableAnalyzers warnings from Microsoft.AI.Foundry.Local.WinML transitive dependency in next PR -->
<NoWarn>$(NoWarn);IDISP001;IDISP002;IDISP003;IDISP004;IDISP006;IDISP007;IDISP008;IDISP017;IDISP025</NoWarn>
<IsAotCompatible>true</IsAotCompatible>
<TrimmerRootDescriptor>SamplesRoots.xml</TrimmerRootDescriptor>
<!-- The *first* DefineConstants is removed during Official Release Builds (.pipelines/Unstub.ps1)-->
Expand Down
8 changes: 8 additions & 0 deletions AIDevGallery/Controls/DownloadProgressList.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ private void RetryDownloadClicked(object sender, RoutedEventArgs e)
if (sender is Button button && button.Tag is DownloadableModel downloadableModel)
{
downloadProgresses.Remove(downloadableModel);

// ModelDownload lifecycle is managed by ModelDownloadQueue, should not be disposed immediately
#pragma warning disable IDISP004 // Don't ignore created IDisposable
App.ModelDownloadQueue.AddModel(downloadableModel.ModelDetails);
#pragma warning restore IDISP004
}
}

Expand All @@ -93,7 +97,11 @@ private void VerificationFailedClicked(object sender, RoutedEventArgs e)
if (sender is Button button && button.Tag is DownloadableModel downloadableModel)
{
downloadProgresses.Remove(downloadableModel);

// ModelDownload lifecycle is managed by ModelDownloadQueue, should not be disposed immediately
#pragma warning disable IDISP004 // Don't ignore created IDisposable
App.ModelDownloadQueue.AddModel(downloadableModel.ModelDetails);
#pragma warning restore IDISP004
}
}
}
9 changes: 9 additions & 0 deletions AIDevGallery/Controls/HomePage/Header/Lights/AmbLight.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,19 @@ internal partial class AmbLight : XamlLight

protected override void OnConnected(UIElement newElement)
{
// Compositor is a shared system resource and should not be disposed
#pragma warning disable IDISP001 // Dispose created
Compositor compositor = CompositionTarget.GetCompositorForCurrentThread();
#pragma warning restore IDISP001

// Dispose previous CompositionLight if exists
CompositionLight?.Dispose();

// Create AmbientLight and set its properties
// Ownership of ambientLight is transferred to CompositionLight property
#pragma warning disable IDISP001 // Dispose created
AmbientLight ambientLight = compositor.CreateAmbientLight();
#pragma warning restore IDISP001
ambientLight.Color = Colors.White;

// Associate CompositionLight with XamlLight
Expand Down
63 changes: 43 additions & 20 deletions AIDevGallery/Controls/HomePage/Header/Lights/HoverLight.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,41 +11,52 @@

namespace AIDevGallery.Controls;

internal partial class HoverLight : XamlLight
internal sealed partial class HoverLight : XamlLight, IDisposable
{
private ExpressionAnimation? _lightPositionExpression;
private Vector3KeyFrameAnimation? _offsetAnimation;
private SpotLight? _spotLight;
private CompositionPropertySet? _hoverPosition;
private static readonly string Id = typeof(HoverLight).FullName!;
private bool _disposed;

protected override void OnConnected(UIElement targetElement)
{
#pragma warning disable IDISP001 // Compositor is provided by the system and should not be disposed
Compositor compositor = CompositionTarget.GetCompositorForCurrentThread();
#pragma warning restore IDISP001

// Create SpotLight and set its properties
SpotLight spotLight = compositor.CreateSpotLight();
spotLight.InnerConeAngleInDegrees = 50f;
spotLight.InnerConeColor = Colors.FloralWhite;
spotLight.OuterConeAngleInDegrees = 20f;
spotLight.ConstantAttenuation = 1f;
spotLight.LinearAttenuation = 0.253f;
spotLight.QuadraticAttenuation = 0.58f;
_spotLight?.Dispose();
_spotLight = compositor.CreateSpotLight();
_spotLight.InnerConeAngleInDegrees = 50f;
_spotLight.InnerConeColor = Colors.FloralWhite;
_spotLight.OuterConeAngleInDegrees = 20f;
_spotLight.ConstantAttenuation = 1f;
_spotLight.LinearAttenuation = 0.253f;
_spotLight.QuadraticAttenuation = 0.58f;

// Associate CompositionLight with XamlLight
CompositionLight = spotLight;
CompositionLight = _spotLight;

// Define resting position Animation
Vector3 restingPosition = new(200, 200, 400);
CubicBezierEasingFunction cbEasing = compositor.CreateCubicBezierEasingFunction(new Vector2(0.3f, 0.7f), new Vector2(0.9f, 0.5f));
_offsetAnimation = compositor.CreateVector3KeyFrameAnimation();
_offsetAnimation.InsertKeyFrame(1, restingPosition, cbEasing);
_offsetAnimation.Duration = TimeSpan.FromSeconds(0.5f);
using (CubicBezierEasingFunction cbEasing = compositor.CreateCubicBezierEasingFunction(new Vector2(0.3f, 0.7f), new Vector2(0.9f, 0.5f)))
{
_offsetAnimation?.Dispose();
_offsetAnimation = compositor.CreateVector3KeyFrameAnimation();
_offsetAnimation.InsertKeyFrame(1, restingPosition, cbEasing);
_offsetAnimation.Duration = TimeSpan.FromSeconds(0.5f);
}

spotLight.Offset = restingPosition;
_spotLight.Offset = restingPosition;

// Define expression animation that relates light's offset to pointer position
CompositionPropertySet hoverPosition = ElementCompositionPreview.GetPointerPositionPropertySet(targetElement);
_hoverPosition?.Dispose();
_hoverPosition = ElementCompositionPreview.GetPointerPositionPropertySet(targetElement);
_lightPositionExpression?.Dispose();
_lightPositionExpression = compositor.CreateExpressionAnimation("Vector3(hover.Position.X, hover.Position.Y, height)");
_lightPositionExpression.SetReferenceParameter("hover", hoverPosition);
_lightPositionExpression.SetReferenceParameter("hover", _hoverPosition);
_lightPositionExpression.SetScalarParameter("height", 100.0f);

// Configure pointer entered/ exited events
Expand Down Expand Up @@ -98,14 +109,26 @@ protected override void OnDisconnected(UIElement oldElement)

// Dispose Light and Composition resources when it is removed from the tree
RemoveTargetElement(GetId(), oldElement);
CompositionLight.Dispose();

_lightPositionExpression?.Dispose();
_offsetAnimation?.Dispose();
Dispose();
}

protected override string GetId()
{
return Id;
}

public void Dispose()
{
if (_disposed)
{
return;
}

_lightPositionExpression?.Dispose();
_offsetAnimation?.Dispose();
_hoverPosition?.Dispose();
_spotLight?.Dispose();
CompositionLight?.Dispose();
_disposed = true;
}
}
9 changes: 8 additions & 1 deletion AIDevGallery/Controls/Markdown/DefaultSVGRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ internal class DefaultSVGRenderer : ISVGRenderer
{
public async Task<Image> SvgToImage(string svgString)
{
// SvgImageSource ownership is transferred to Image.Source, so it should not be disposed here
#pragma warning disable IDISP004 // Don't ignore created IDisposable
SvgImageSource svgImageSource = new SvgImageSource();
#pragma warning restore IDISP004
var image = new Image();

// Create a MemoryStream object and write the SVG string to it
Expand All @@ -27,7 +30,11 @@ public async Task<Image> SvgToImage(string svgString)
memoryStream.Position = 0;

// Load the SVG from the MemoryStream
await svgImageSource.SetSourceAsync(memoryStream.AsRandomAccessStream());
// AsRandomAccessStream() returns a wrapper that should not be disposed - it's owned by SvgImageSource after SetSourceAsync
#pragma warning disable IDISP001 // Dispose created
var randomAccessStream = memoryStream.AsRandomAccessStream();
#pragma warning restore IDISP001
await svgImageSource.SetSourceAsync(randomAccessStream);
}

// Set the Source property of the Image control to the SvgImageSource object
Expand Down
62 changes: 30 additions & 32 deletions AIDevGallery/Controls/Markdown/TextElements/MyImage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ namespace CommunityToolkit.Labs.WinUI.MarkdownTextBlock.TextElements;

internal class MyImage : IAddChild
{
private static readonly HttpClient _httpClient = new HttpClient();
private InlineUIContainer _container = new InlineUIContainer();
private LinkInline? _linkInline;
private Image _image = new Image();
Expand Down Expand Up @@ -108,48 +109,45 @@ private async void LoadImage(object sender, RoutedEventArgs e)
}
else
{
using (HttpClient client = new())
{
// Download data from URL
HttpResponseMessage response = await client.GetAsync(_uri);
// Download data from URL
using HttpResponseMessage response = await _httpClient.GetAsync(_uri);

// Get the Content-Type header
// Get the Content-Type header
#pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type.
#pragma warning disable CS8602 // Dereference of a possibly null reference.
string contentType = response.Content.Headers.ContentType.MediaType;
string contentType = response.Content.Headers.ContentType.MediaType;
#pragma warning restore CS8602 // Dereference of a possibly null reference.
#pragma warning restore CS8600 // Converting null literal or possible null value to non-nullable type.

if (contentType == "image/svg+xml")
if (contentType == "image/svg+xml")
{
var svgString = await response.Content.ReadAsStringAsync();
var resImage = await _svgRenderer.SvgToImage(svgString);
if (resImage != null)
{
var svgString = await response.Content.ReadAsStringAsync();
var resImage = await _svgRenderer.SvgToImage(svgString);
if (resImage != null)
{
_image = resImage;
_container.Child = _image;
}
_image = resImage;
_container.Child = _image;
}
else
}
else
{
byte[] data = await response.Content.ReadAsByteArrayAsync();

// Create a BitmapImage for other supported formats
BitmapImage bitmap = new BitmapImage();
using (InMemoryRandomAccessStream stream = new InMemoryRandomAccessStream())
{
byte[] data = await response.Content.ReadAsByteArrayAsync();

// Create a BitmapImage for other supported formats
BitmapImage bitmap = new BitmapImage();
using (InMemoryRandomAccessStream stream = new InMemoryRandomAccessStream())
{
// Write the data to the stream
await stream.WriteAsync(data.AsBuffer());
stream.Seek(0);

// Set the source of the BitmapImage
await bitmap.SetSourceAsync(stream);
}

_image.Source = bitmap;
_image.Width = bitmap.PixelWidth == 0 ? bitmap.DecodePixelWidth : bitmap.PixelWidth;
_image.Height = bitmap.PixelHeight == 0 ? bitmap.DecodePixelHeight : bitmap.PixelHeight;
// Write the data to the stream
await stream.WriteAsync(data.AsBuffer());
stream.Seek(0);

// Set the source of the BitmapImage
await bitmap.SetSourceAsync(stream);
}

_image.Source = bitmap;
_image.Width = bitmap.PixelWidth == 0 ? bitmap.DecodePixelWidth : bitmap.PixelWidth;
_image.Height = bitmap.PixelHeight == 0 ? bitmap.DecodePixelHeight : bitmap.PixelHeight;
}

_loaded = true;
Expand Down
Loading
Loading