Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f52ad09
[Graphics] Add image metadata + load/save options API (net11)
mattleibow Jul 14, 2026
2322acd
[Graphics] Implement image metadata API for iOS, MacCatalyst, and macOS
mattleibow Jul 15, 2026
cfd5000
[Graphics] Declare metadata API in net-windows/net-tizen PublicAPI
mattleibow Jul 15, 2026
f1b365b
[Android] MediaPicker: never mutate the picked source file
mattleibow Jul 15, 2026
9551666
[Graphics] Modernize iOS/MacCatalyst ScaleImage to UIGraphicsImageRen…
mattleibow Jul 15, 2026
8def50d
Fold metadata API into IImage/IImageLoadingService via default interf…
mattleibow Jul 15, 2026
2564f9d
Revert net-tizen PublicAPI changes (Tizen is no longer built)
mattleibow Jul 15, 2026
c1c9a35
[Graphics] Implement Windows image metadata + EXIF orientation
mattleibow Jul 15, 2026
bd3b40e
[Essentials] Unify MediaPicker image processing on the shared Graphic…
mattleibow Jul 15, 2026
c7cefd1
[Essentials] Add cross-platform MediaPicker processing device test
mattleibow Jul 15, 2026
2c4d35c
[Graphics] Fix Windows metadata compile errors (net-windows / CsWinRT)
mattleibow Jul 15, 2026
66716af
[Graphics] Fix iOS/MacCatalyst SaveAsync(options) NRE on CoreCLR
mattleibow Jul 15, 2026
66c463c
[Essentials] Keep Android MediaPicker recovery contract in the shared…
mattleibow Jul 15, 2026
692f3c2
[Essentials] Tidy the shared MediaPicker image processing API
mattleibow Jul 15, 2026
feae98a
[Essentials/Graphics] MediaPicker/loading cleanups + fix iOS build
mattleibow Jul 15, 2026
580fa15
[Graphics/Essentials] Loading lives in the loading services; iOS came…
mattleibow Jul 15, 2026
935e4ee
[Graphics] Keep decode work on PlatformImage.FromStream; loading serv…
mattleibow Jul 15, 2026
f94f156
[Graphics] Make PlatformImage.FromStream(stream, ImageLoadOptions) pu…
mattleibow Jul 16, 2026
81f35a3
[Graphics] Fix Windows build break + iOS resize orientation desync
mattleibow Jul 16, 2026
7605162
[Graphics/Essentials] Nullability on new APIs, drop IsExternalInit, c…
mattleibow Jul 16, 2026
3bbddeb
[Graphics/Essentials] Drop unused usings; clarify Android recovery-re…
mattleibow Jul 16, 2026
a9116d9
[Graphics] Clarify resize/orientation comment (Windows) and options-D…
mattleibow Jul 16, 2026
34d39b9
[Graphics] Make ImageLoadOptions/ImageSaveOptions plain option classes
mattleibow Jul 18, 2026
e4078d0
[Graphics/Essentials] Skia options-save clamps quality; fix stale Com…
mattleibow Jul 19, 2026
9a9fd61
Merge remote-tracking branch 'origin/net11.0' into mattleibow-net11-g…
mattleibow Aug 11, 2026
bcbf34e
[Graphics/Essentials] Null-safe options APIs; preserve original image…
mattleibow Aug 11, 2026
474c3e0
[Essentials] Test: MediaPicker preserves original .jpeg extension
mattleibow Aug 11, 2026
aed12ed
[Graphics] Fix Windows build break: fully-qualify Windows.Foundation.…
mattleibow Aug 11, 2026
f01742f
[Graphics/Essentials] Android: guard null decode; correct source-fall…
mattleibow Aug 11, 2026
cadc07d
Merge remote-tracking branch 'origin/net11.0' into mattleibow-net11-g…
mattleibow Aug 11, 2026
1ec71cd
[Essentials] iOS MediaPicker: fix rotated content type; clean cache d…
mattleibow Aug 11, 2026
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
273 changes: 91 additions & 182 deletions src/Essentials/src/MediaPicker/MediaPicker.android.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using Microsoft.Maui.Storage;
using static AndroidX.Activity.Result.Contract.ActivityResultContracts;
using AndroidUri = Android.Net.Uri;
using MG = Microsoft.Maui.Graphics;

namespace Microsoft.Maui.Media
{
Expand All @@ -24,72 +25,116 @@ partial class MediaPickerImplementation : IMediaPicker
public bool IsCaptureSupported
=> Application.Context?.PackageManager?.HasSystemFeature(PackageManager.FeatureCameraAny) ?? false;

static async Task RotateImageInPlace(string filePath, MediaPickerOptions options)
{
await using var inputStream = File.OpenRead(filePath);
var fileName = System.IO.Path.GetFileName(filePath);
await using var rotatedStream = await ImageProcessor.RotateImageAsync(inputStream, fileName);
rotatedStream.Position = 0;
inputStream.Dispose(); // explicit close before delete
try
{ File.Delete(filePath); }
catch { }
await using var outputStream = File.Create(filePath);
await rotatedStream.CopyToAsync(outputStream);
}
internal static Task<string> ProcessPhotoAsync(string imagePath, MediaPickerOptions options)
=> ProcessPhotoPreservingSourceAsync(imagePath, GetPhotoProcessingOptions(options));

internal static async Task<string> ProcessPhotoAsync(string imagePath, MediaPickerOptions options)
internal static async Task<string> ProcessPhotoPreservingSourceAsync(string imagePath, PersistedPhotoProcessingOptions options)
{
// Apply rotation if needed for photos
if (imagePath is not null && ImageProcessor.IsRotationNeeded(options))
if (imagePath is null)
{
await RotateImageInPlace(imagePath, options);
return null;
}

// Apply compression/resizing if needed for photos
if (imagePath is not null && ImageProcessor.IsProcessingNeeded(options?.MaximumWidth, options?.MaximumHeight, options?.CompressionQuality ?? 100))
// Nothing to do unless the caller asked to rotate, resize, or recompress.
if (!options.RotateImage &&
!ImageProcessor.IsProcessingNeeded(options.MaximumWidth, options.MaximumHeight, options.CompressionQuality))
{
imagePath = await CompressImageIfNeeded(imagePath, options);
return imagePath;
}
Comment thread
mattleibow marked this conversation as resolved.
Outdated

return imagePath;
// Never mutate or delete the source file. Picked files can live in external or otherwise
// unowned locations (an SD card, another app's shared storage, or the user's original gallery
// item), so we only ever read the source and write the processed result to a new MAUI-owned
// cache file.
var processed = await ProcessImageWithGraphicsAsync(imagePath, options);
return processed ?? imagePath;
}

internal static async Task<string> ProcessPhotoPreservingSourceAsync(string imagePath, PersistedPhotoProcessingOptions options)
// Loads the image through MAUI Graphics (applying EXIF orientation and capturing metadata per the
// options), applies any resize, and writes the result to a new cache file that preserves the
// original file name. The source file is only ever read, never modified.
static async Task<string> ProcessImageWithGraphicsAsync(string imagePath, PersistedPhotoProcessingOptions options)
{
if (imagePath is null)
if (string.IsNullOrEmpty(imagePath) || !File.Exists(imagePath))
{
return null;
return imagePath;
}

var originalImagePath = imagePath;
string rotatedImagePath = null;

// Recovery-sensitive MediaPicker paths must leave the original file intact until the
// active recovery record has been cleared or promoted.
if (options.RotateImage)
try
{
var rotatedPath = await RotateImageToNewFileAsync(imagePath);
if (!string.Equals(rotatedPath, imagePath, StringComparison.Ordinal))
var loadOptions = new MG.ImageLoadOptions
{
rotatedImagePath = rotatedPath;
}
DisableRotationNormalization = !options.RotateImage,
PreserveMetadata = options.PreserveMetaData,
};

imagePath = rotatedPath;
}
var loadingService = new MG.Platform.PlatformImageLoadingService();

if (ImageProcessor.IsProcessingNeeded(options.MaximumWidth, options.MaximumHeight, options.CompressionQuality))
{
var compressedImagePath = await CompressImageIfNeeded(imagePath, options, preserveSource: true);
if (ShouldDeleteIntermediateFile(rotatedImagePath, originalImagePath, compressedImagePath))
MG.IImage image;
using (var inputStream = File.OpenRead(imagePath))
{
TryDeleteFile(rotatedImagePath);
image = loadingService.FromStream(inputStream, loadOptions);
}

imagePath = compressedImagePath;
}
if (image is null)
{
return imagePath;
}

using (image)
{
var current = image;
if (options.MaximumWidth is not null || options.MaximumHeight is not null)
{
current = image.Downsize(
options.MaximumWidth ?? int.MaxValue,
options.MaximumHeight ?? int.MaxValue,
disposeOriginal: false);
}

try
{
// Preserve the original container (JPEG/PNG). Deterministic: no automatic format switching.
var originalExtension = System.IO.Path.GetExtension(imagePath);
var isPng = string.Equals(originalExtension, FileExtensions.Png, StringComparison.OrdinalIgnoreCase);
var format = isPng ? MG.ImageFormat.Png : MG.ImageFormat.Jpeg;
var outputExtension = isPng ? FileExtensions.Png : FileExtensions.Jpg;
Comment thread
mattleibow marked this conversation as resolved.
Outdated

return imagePath;
var outputFileName = System.IO.Path.GetFileNameWithoutExtension(imagePath) + outputExtension;
var outputFile = FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, outputFileName);
Comment thread
mattleibow marked this conversation as resolved.
Outdated

Comment thread
mattleibow marked this conversation as resolved.
Outdated
var saveOptions = new MG.ImageSaveOptions
{
Quality = Math.Max(0, Math.Min(100, options.CompressionQuality)) / 100f,
PreserveMetadata = options.PreserveMetaData,
};

using var outputStream = File.Create(outputFile.AbsolutePath);
if (current is MG.IImageWithMetadata imageWithMetadata)
{
await imageWithMetadata.SaveAsync(outputStream, format, saveOptions);
}
else
{
await current.SaveAsync(outputStream, format, saveOptions.Quality);
}

return outputFile.AbsolutePath;
}
finally
{
if (current != image)
{
current.Dispose();
}
}
}
}
catch
{
// On any failure, fall back to the untouched original file.
return imagePath;
}
}

internal static PersistedPhotoProcessingOptions GetPhotoProcessingOptions(MediaPickerOptions options)
Expand Down Expand Up @@ -278,15 +323,7 @@ void OnResult(Intent intent)
{
if (photo)
{
// Apply rotation if needed
if (ImageProcessor.IsRotationNeeded(options))
await RotateImageInPlace(path, options);

// Apply compression/resizing if needed
if (ImageProcessor.IsProcessingNeeded(options?.MaximumWidth, options?.MaximumHeight, options?.CompressionQuality ?? 100))
{
path = await CompressImageIfNeeded(path, options);
}
path = await ProcessPhotoAsync(path, options);
}

return new FileResult(path);
Expand Down Expand Up @@ -427,123 +464,6 @@ void OnCreate(Intent intent)
return captureFile.AbsolutePath;
}

static async Task<string> RotateImageToNewFileAsync(string imagePath)
{
if (string.IsNullOrEmpty(imagePath))
{
return imagePath;
}

if (!File.Exists(imagePath))
{
return imagePath;
}

await using var inputStream = File.OpenRead(imagePath);
var inputFileName = System.IO.Path.GetFileName(imagePath);
await using var rotatedStream = await ImageProcessor.RotateImageAsync(inputStream, inputFileName);
rotatedStream.Position = 0;

var outputExtension = System.IO.Path.GetExtension(imagePath);
if (string.IsNullOrEmpty(outputExtension))
{
outputExtension = FileExtensions.Jpg;
}

var outputFile = FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, Guid.NewGuid().ToString("N") + outputExtension);
await using var outputStream = File.Create(outputFile.AbsolutePath);
await rotatedStream.CopyToAsync(outputStream);

return outputFile.AbsolutePath;
}

static bool ShouldDeleteIntermediateFile(string intermediatePath, string originalPath, string finalPath)
=> !string.IsNullOrEmpty(intermediatePath) &&
!string.Equals(intermediatePath, originalPath, StringComparison.Ordinal) &&
!string.Equals(intermediatePath, finalPath, StringComparison.Ordinal);

static void TryDeleteFile(string filePath)
{
try
{ File.Delete(filePath); }
catch { }
}

static Task<string> CompressImageIfNeeded(string imagePath, MediaPickerOptions options, bool preserveSource = false)
=> CompressImageIfNeeded(imagePath, GetPhotoProcessingOptions(options), preserveSource);

static async Task<string> CompressImageIfNeeded(string imagePath, PersistedPhotoProcessingOptions options, bool preserveSource = false)
{
if (!ImageProcessor.IsProcessingNeeded(options.MaximumWidth, options.MaximumHeight, options.CompressionQuality) || string.IsNullOrEmpty(imagePath))
return imagePath;

try
{
var originalFile = new Java.IO.File(imagePath);
if (!originalFile.Exists())
{
return imagePath;
}

// Use ImageProcessor for unified image processing
using var inputStream = File.OpenRead(imagePath);
var inputFileName = System.IO.Path.GetFileName(imagePath);
using var processedStream = await ImageProcessor.ProcessImageAsync(
inputStream,
options.MaximumWidth,
options.MaximumHeight,
options.CompressionQuality,
inputFileName,
options.RotateImage,
options.PreserveMetaData);

if (processedStream != null)
{
// Determine the correct output extension based on the processed format
processedStream.Position = 0;
var outputExtension = ImageProcessor.DetermineOutputExtension(processedStream, options.CompressionQuality, inputFileName);
var originalExtension = System.IO.Path.GetExtension(imagePath);

// If format changed (e.g., PNG -> JPEG), use new extension
string outputPath = imagePath;
if (!string.Equals(outputExtension, originalExtension, StringComparison.OrdinalIgnoreCase))
{
outputPath = System.IO.Path.ChangeExtension(imagePath, outputExtension);
}

if (preserveSource)
{
var outputFile = FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, Guid.NewGuid().ToString("N") + outputExtension);
outputPath = outputFile.AbsolutePath;
}
else
{
// Delete original file first
try
{ originalFile.Delete(); }
catch { }
}

// Write processed image to output path with correct extension
using var outputStream = File.Create(outputPath);
processedStream.Position = 0;
await processedStream.CopyToAsync(outputStream);

return outputPath;
}

// If ImageProcessor returns null (e.g., on .NET Standard), ImageProcessor.IsProcessingNeeded would have returned false,
// so we shouldn't reach this point. Return original path as fallback.
return imagePath;
}
catch
{
// If processing fails, return original path
}

return imagePath;
}

async Task<string> CaptureVideoAsync(Intent captureIntent)
{
string path = null;
Expand Down Expand Up @@ -737,18 +657,7 @@ void OnResult(Intent resultIntent)

foreach (var path in tempResultList)
{
string processedPath = path;

// Apply rotation if needed
if (ImageProcessor.IsRotationNeeded(options))
await RotateImageInPlace(processedPath, options);

// Apply compression/resizing if needed
if (ImageProcessor.IsProcessingNeeded(options?.MaximumWidth, options?.MaximumHeight, options?.CompressionQuality ?? 100))
{
processedPath = await CompressImageIfNeeded(processedPath, options);
}

var processedPath = await ProcessPhotoAsync(path, options);
resultList.Add(new FileResult(processedPath));
}
}
Expand Down
Loading
Loading