[net11] Image metadata API in Graphics; stop Android MediaPicker mutating picked source files - #36581
[net11] Image metadata API in Graphics; stop Android MediaPicker mutating picked source files#36581mattleibow wants to merge 31 commits into
Conversation
Introduce a public, non-breaking image metadata API on MAUI Graphics so callers (e.g. Essentials MediaPicker) can preserve EXIF metadata and control EXIF orientation normalization across a load/transform/save round trip. Because Graphics multi-targets netstandard2.0 (no default interface members), the capability is exposed via new interfaces rather than new members on IImage: - ImageLoadOptions (DisableRotationNormalization, PreserveMetadata) - ImageSaveOptions (Quality, PreserveMetadata) - IImageMetadata (opaque) - IImageWithMetadata : IImage (Metadata + Save/SaveAsync with options) - IMetadataImageLoadingService : IImageLoadingService (FromStream with options) Defaults reproduce the current behavior exactly (orientation normalized, metadata dropped), so existing consumers are unaffected. Android implementation: - PlatformImage implements IImageWithMetadata; carries metadata through Downsize. - New options-based load captures EXIF (JPEG) via ExifInterface without auto-rotating when requested, and normalizes the retained Orientation to 1 when the pixels are rotated upright (avoids double-rotation on save). - Metadata-aware save re-embeds the captured EXIF into the output JPEG. - Adds an IsExternalInit polyfill so record structs/init work on netstandard2.0. Other platforms (iOS/MacCatalyst/Windows/Mac) to follow in subsequent commits. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9944e2fe-a9b8-425d-b596-3d9f163ca3d5
- iOS/MacCatalyst PlatformImage implements IImageWithMetadata: options-based load captures CGImage properties (EXIF/GPS) via CGImageSource and honors DisableRotationNormalization; metadata-aware save re-embeds properties (with Orientation normalized to 1) via CGImageDestination for JPEG/PNG. Metadata is carried through Downsize. - macOS PlatformImage implements the interface with a pixel-only save/load (AppKit metadata preservation deferred; not a MediaPicker target). - MaciOS PlatformImageLoadingService implements IMetadataImageLoadingService. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9944e2fe-a9b8-425d-b596-3d9f163ca3d5
The new shared metadata types compile into every TFM; Windows and Tizen platform code is intentionally left unchanged (shared types only). These PublicAPI entries are added manually because those TFMs cannot be built on macOS to auto-generate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9944e2fe-a9b8-425d-b596-3d9f163ca3d5
MediaPicker photo processing (rotate/compress) previously deleted and rewrote the resolved source path in place. Picked files can resolve to external or otherwise unowned locations (an SD card, another app's shared storage, or the user's original gallery item), so mutating/deleting them corrupts data the app does not own, and the delete-before-write also risked losing the original on a failed write. Android photo processing now routes through the MAUI Graphics image metadata API: it reads the source (never writing to it), applies EXIF orientation and resize, and writes the result to a NEW app-cache file that preserves the original file name (issue #33258). This also fixes RotateImage=false previously still rotating, makes the output format deterministic (preserve original container), and preserves EXIF metadata via Graphics. - ProcessPhotoAsync now delegates to a non-mutating, Graphics-based pipeline. - The two legacy ACTION_GET_CONTENT fallback paths (the only ones that mutated an externally-supplied path) now use it too. - Removes the in-place RotateImageInPlace/CompressImageIfNeeded/RotateImageToNewFile helpers. iOS/Windows are unchanged (they already produce new files). - Adds Android device tests asserting the source is untouched, output lands in cache with the original file name, and EXIF metadata is preserved. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9944e2fe-a9b8-425d-b596-3d9f163ca3d5
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 36581Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 36581" |
|
Azure Pipelines: Successfully started running 1 pipeline(s). There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
This PR introduces a new opt-in image metadata pipeline in Microsoft.Maui.Graphics (via new interfaces/options) and updates Android MediaPicker image processing to stop modifying/deleting the picked source file by routing processing through that pipeline.
Changes:
- Add new public Graphics APIs (
ImageLoadOptions/ImageSaveOptions,IImageWithMetadata,IImageMetadata,IMetadataImageLoadingService) to support metadata capture/preservation and configurable EXIF orientation normalization. - Implement metadata capture + orientation normalization + re-embed on save for Android and iOS/MacCatalyst (with a macOS “pixel-only” implementation for API parity).
- Update Android
MediaPickerprocessing to read-only from the source and write a new cache file (plus add Android device tests to validate no source mutation, filename preservation, and EXIF preservation).
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Graphics/src/Graphics/PublicAPI/netstandard/PublicAPI.Unshipped.txt | Public API updates for new Graphics metadata interfaces/options (netstandard). |
| src/Graphics/src/Graphics/PublicAPI/net/PublicAPI.Unshipped.txt | Public API updates for new Graphics metadata interfaces/options (net). |
| src/Graphics/src/Graphics/PublicAPI/net-windows/PublicAPI.Unshipped.txt | Public API updates for Windows target (includes new metadata interfaces/options). |
| src/Graphics/src/Graphics/PublicAPI/net-tizen/PublicAPI.Unshipped.txt | Public API updates for Tizen target (includes new metadata interfaces/options). |
| src/Graphics/src/Graphics/PublicAPI/net-macos/PublicAPI.Unshipped.txt | Public API updates for macOS target (includes new metadata interfaces/options). |
| src/Graphics/src/Graphics/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt | Public API updates for MacCatalyst target (includes new metadata interfaces/options). |
| src/Graphics/src/Graphics/PublicAPI/net-ios/PublicAPI.Unshipped.txt | Public API updates for iOS target (includes new metadata interfaces/options). |
| src/Graphics/src/Graphics/PublicAPI/net-android/PublicAPI.Unshipped.txt | Public API updates for Android target (includes new metadata interfaces/options). |
| src/Graphics/src/Graphics/Platforms/MaciOS/PlatformImageLoadingService.cs | Implements metadata-capable loading service on iOS/MacCatalyst. |
| src/Graphics/src/Graphics/Platforms/Mac/PlatformImage.cs | Makes macOS PlatformImage implement IImageWithMetadata (pixel-only options impl). |
| src/Graphics/src/Graphics/Platforms/iOS/PlatformImage.cs | Adds metadata capture/preserve + configurable orientation normalization + save-with-options for iOS. |
| src/Graphics/src/Graphics/Platforms/iOS/AppleImageMetadata.cs | New iOS/MacCatalyst metadata implementation capturing ImageIO properties for re-embedding. |
| src/Graphics/src/Graphics/Platforms/Android/PlatformImageLoadingService.cs | Implements metadata-capable loading service on Android. |
| src/Graphics/src/Graphics/Platforms/Android/PlatformImage.cs | Adds EXIF capture/preserve + configurable orientation normalization + save-with-options for Android. |
| src/Graphics/src/Graphics/Platforms/Android/AndroidImageMetadata.cs | New Android metadata implementation capturing selected EXIF tags for JPEG re-embedding. |
| src/Graphics/src/Graphics/IsExternalInit.cs | Adds IsExternalInit shim to support init/records on netstandard2.0 targets. |
| src/Graphics/src/Graphics/IMetadataImageLoadingService.cs | New public interface for loading images with ImageLoadOptions. |
| src/Graphics/src/Graphics/ImageSaveOptions.cs | New public options struct for saving images with quality + metadata preservation. |
| src/Graphics/src/Graphics/ImageLoadOptions.cs | New public options struct for loading images with orientation normalization + metadata capture. |
| src/Graphics/src/Graphics/IImageWithMetadata.cs | New public interface enabling metadata + save-with-options. |
| src/Graphics/src/Graphics/IImageMetadata.cs | New public opaque metadata interface. |
| src/Essentials/test/DeviceTests/Tests/Android/MediaPicker_Tests.cs | New Android device tests asserting source file isn’t mutated and EXIF/filename behavior. |
| src/Essentials/src/MediaPicker/MediaPicker.android.cs | Switches Android photo processing to Graphics pipeline and stops in-place source mutation. |
…derer UIImageExtensions.ScaleImage used the deprecated UIGraphics.BeginImageContext / GetImageFromCurrentImageContext / EndImageContext APIs, which are flagged as unsupported on MacCatalyst 17+ (CA1416) by the net11 MacCatalyst 26.5 bindings and fail the Release build. Switch to UIGraphicsImageRenderer, matching the approach already used by NormalizeOrientation in the same file. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9944e2fe-a9b8-425d-b596-3d9f163ca3d5
…ace members Replace the separate IImageWithMetadata / IMetadataImageLoadingService interfaces with members added directly to IImage and IImageLoadingService, using the established repo pattern (abstract on netstandard2.0, default interface member elsewhere) as in Core's IPicker. - IImage: add Metadata, Save(stream, format, ImageSaveOptions), SaveAsync(stream, format, ImageSaveOptions). - IImageLoadingService: add FromStream(stream, ImageLoadOptions). - Delete IImageWithMetadata and IMetadataImageLoadingService. - Revert platform implementers (Android/iOS/Mac PlatformImage, Android/MaciOS PlatformImageLoadingService) back to : IImage / : IImageLoadingService; their existing methods now satisfy the members. - Implement the members on the netstandard2.0 implementers (root PlatformImage, SkiaImage, SkiaImageLoadingService) so the abstract branch compiles; Windows/Mac inherit the default members. - MediaPicker.android: drop the IImageWithMetadata cast; call SaveAsync(options) on IImage directly. - Update Graphics + Graphics.Skia PublicAPI (all TFMs). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9944e2fe-a9b8-425d-b596-3d9f163ca3d5
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 44 out of 44 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
src/Graphics/src/Graphics/Platforms/Android/PlatformImage.cs:330
- Same disposal issue in the options-based loader:
ExifInterfaceis created but never disposed, which can leak resources when loading many images.
try
{
var exif = new ExifInterface(seekableStream);
orientation = exif.GetAttributeInt(ExifInterface.TagOrientation, 1);
if (options.PreserveMetadata)
{
metadata = AndroidImageMetadata.Capture(exif, orientation);
}
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 3 findings
See inline comments for details.
<!-- Please let the below note in for people that find this PR --> > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ## Summary - Replace the unsupported `UIGraphics.BeginImageContext` image-scaling path with a private `CGBitmapContext` pushed onto UIKit's per-thread graphics stack. - Preserve the legacy 1x backing scale, transparent output, standard color range, image orientation, rendering behavior, and the public `CGSize` overload's no-op behavior for nonpositive dimensions. - Keep synchronous `ScaleImage` usable from background threads without changing process-global UIKit diagnostics or synchronously depending on main-queue progress. - Carry only the isolated Graphics fix from #36581, without the unrelated image-metadata API work. ## Failure addressed The .NET 11 iOS 26.5 bindings mark `BeginImageContext`, `GetImageFromCurrentImageContext`, and `EndImageContext` unsupported on iOS and MacCatalyst 17 or later. These `CA1416` diagnostics are promoted to errors and stop affected builds before tests run, as demonstrated by [build 1537895](https://dev.azure.com/dnceng-public/public/_build/results?buildId=1537895). PR #37090 provides the intentionally minimal Preview 7 build unblock by suppressing those diagnostics. This PR is the durable `net11.0`/RC1 implementation that removes the dependency on the unsupported APIs. ## Coverage - `ScaleImageUsesOneXBackingScale` covers 1x, 2x, and 3x source images and verifies returned scale and backing pixel dimensions. - `ScaleImageReturnsOriginalForNonPositiveSize` covers zero and negative width and height values, including `disposeOriginal` behavior. - `ScaleImageCanRunOnBackgroundThread` verifies ordinary worker-thread use. - `ScaleImageDoesNotRequireMainThreadProgress` blocks the main thread while scaling on a worker and verifies the synchronous API cannot deadlock waiting for main-queue progress. - `ScaleImageMatchesUIKitRendering` compares exact pixels against `UIGraphicsImageRenderer` for all eight orientations using fractional dimensions, alpha, and transparency. - The implementation previously passed all 50 Graphics device tests on both iOS and MacCatalyst; fresh `net11.0` CI will validate the retargeted PR. ### Issues Fixed N/A ---------
…raphics-image-metadata # Conflicts: # src/Essentials/src/MediaPicker/MediaPicker.android.cs # src/Essentials/src/MediaPicker/MediaPicker.ios.cs # src/Essentials/test/DeviceTests/Tests/Android/MediaPicker_Tests.cs # src/Graphics/src/Graphics/Platforms/iOS/UIImageExtensions.cs
… extension Addresses AI review feedback on the new options-based APIs. Null-safety: now that ImageLoadOptions/ImageSaveOptions are classes (reference types), a null argument would NRE. Every public options-based entry point (IImage.Save/SaveAsync and IImageLoadingService.FromStream default implementations, plus the Android/iOS/macOS/Windows/ netstandard PlatformImage and SkiaImage implementations) now treats a null options as default options (options ??= new ...()), matching MAUI's convention that options bags are optional (e.g. Browser/FilePicker). No throw is introduced. Original file name preservation (#33258): GetOutputExtension now has an overload that keeps the source extension when it already denotes the same output container — so a picked "photo.jpeg" stays ".jpeg" instead of being rewritten to ".jpg". The extension is only normalized when the container actually changes (e.g. HEIC -> JPEG). Wired through the Android and iOS MediaPicker paths and ProcessImageToCacheFileAsync. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9944e2fe-a9b8-425d-b596-3d9f163ca3d5
Adds ProcessImage_PreservesOriginalJpegExtension to the shared MediaPicker device tests. A picked "picked.jpeg" must keep its exact ".jpeg" extension through the Graphics-based processing pipeline (not be rewritten to ".jpg"), preserving the original file name (#33258). Complements the extension-preservation fix in bcbf34e. Verified locally on MacCatalyst (net11.0-maccatalyst26.5): passes alongside the existing ProcessImage_Downsizes_ToNewCacheFile and ProcessImage_PreservesJpegContainer. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c5a9b372-d7cc-4bbd-abf6-eaa03793d318
…PropertyType CI (Pack Windows, Release) failed with CS0103: 'PropertyType' does not exist in the current context at WindowsImageMetadata.cs(99). The BitmapTypedValue(object, PropertyType) constructor takes Windows.Foundation.PropertyType, but this file only imports Windows.Graphics.Imaging, so the unqualified name wasn't in scope. Fully-qualify it as global::Windows.Foundation.PropertyType.UInt16 (the sibling PlatformImage.cs in this folder likewise depends on Windows.Foundation). Windows-only; verified against the WinRT API docs since Windows can't be built on the macOS dev host. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c5a9b372-d7cc-4bbd-abf6-eaa03793d318
This comment has been minimized.
This comment has been minimized.
…back doc Addresses two older Copilot review comments that predated the earlier reply passes. Graphics (Android PlatformImage): BitmapFactory.DecodeStream returns null (rather than throwing) for empty/corrupt/unsupported data. Previously that null flowed into RotateBitmap and the PlatformImage wrapper, surfacing later as an opaque NullReferenceException. Add a DecodeOrThrow helper that raises a controlled ArgumentException (matching the FromStream contract for invalid image data) and route all five decode sites — the pre-existing plain FromStream path and the new options-based load path — through it. Essentials (MediaPicker.android): the ProcessPhotoPreservingSourceAsync comment claimed the source path is only returned in the early-out cases; it can also be returned as a last resort when the post-failure fallback File.Copy itself throws. Reword the comment to match. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c5a9b372-d7cc-4bbd-abf6-eaa03793d318
This comment has been minimized.
This comment has been minimized.
…raphics-image-metadata Brings in the latest net11.0, notably #35805 ([iOS] Fix MediaPicker FullPath for PHPicker results, fixes #32832). Conflict: src/Essentials/src/MediaPicker/MediaPicker.ios.cs. #35805 rewrote the iOS PHPicker path to materialize each picked item to a real temp file so FileResult.FullPath is immediately usable for direct filesystem access, adding a thread-safe eager-load model (PHPickerFileResult.LoadFileRepresentationAsync / PHPickerProcessedFileResult. LoadProcessedFileAsync) with disposal and temp-file sweeping. This branch had rewritten the same code onto the unified shared Graphics ImageProcessor and removed the old per-platform processing APIs (RotateImageAsync / DetermineOutputExtension / multi-arg ProcessImageAsync). Resolved by combining both: kept #35805's eager materialization, thread-safety, disposal and #32832 FullPath fix, but routed image processing through the shared pipeline. Specifically: - PickerResultsToMediaFiles is now async and eagerly LoadFileRepresentationAsync's each pick (real FullPath), then wraps images in a single-pass PHPickerProcessedFileResult that is eagerly materialized so processed results also expose a ready-to-read FullPath. - PHPickerProcessedFileResult keeps #35805's thread-safe LoadProcessedFileAsync skeleton but its body is a single ImageProcessor.ProcessImageToCacheFileAsync call (rotation + resize + recompress in one Graphics pass). Because processed output lives in a unique app-cache sub-directory (not the MediaPicker temp folder), it is cleaned up directly; the picked original is still reclaimed via TryDeleteTemporaryFile. - RotateImageFile keeps the shared-pipeline implementation. Verified: Essentials builds clean on iOS, MacCatalyst, Android and netstandard2.0; Graphics builds clean on Android.
…ir on failure Two robustness fixes surfaced while reconciling the net11.0 merge: - RotateImageFile built `new FileResult(outputPath, original.FileName)`, but the second argument of that constructor is the *content type* — so a rotated pick reported a content type like "IMG_1234.jpg" instead of "image/jpeg". Derive the real MIME type from the output container and carry the original file name via the FileName initializer. - ProcessImageToCacheFileAsync created its unique cache sub-directory and output file before encoding; if processing threw, the caller never received the path and the directory leaked. Delete the output directory on failure before rethrowing (affects every caller — iOS, Android, Windows). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c5a9b372-d7cc-4bbd-abf6-eaa03793d318
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
AI Review Summary
@mattleibow — new AI review results are available based on commit
1ec71cd.
🗂️ Review Sessions — click to expand
🚦 Gate — Test Before & After Fix
Gate Result: ⚠️ INCONCLUSIVE
Platform: ANDROID · Base: net11.0 · Merge base: 87f2c23e
🩺 Pre-existing build failure (not the fix) — both the without-fix baseline AND the with-fix build fail with a build error, so the PR's fix is NOT the cause. This is a broken main/merge-base or a toolchain/environment failure (e.g. an ILLink IL1012 trimmer crash). The gate cannot verify anything; investigate the build environment rather than the PR.
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error MSB6006: "gradlew" exited with code 1. [/home/vsts/w...
| Test | Without Fix (expect FAIL) | With Fix (expect PASS) |
|---|---|---|
📱 MediaPicker_Shared_Tests (ProcessImage_Downsizes_ToNewCacheFile, ProcessImage_PreservesJpegContainer, ProcessImage_PreservesOriginalJpegExtension) Category=MediaPicker |
🛠️ BUILD ERROR | 🛠️ BUILD ERROR |
📱 Android_MediaPicker_Tests (ProcessPhoto_Rotate_DoesNotModifyOrDeleteSource, ProcessPhoto_PreservesExifMetadata) Category=MediaPicker |
🛠️ BUILD ERROR | 🛠️ BUILD ERROR |
🔴 Without fix — 📱 MediaPicker_Shared_Tests (ProcessImage_Downsizes_ToNewCacheFile, ProcessImage_PreservesJpegContainer, ProcessImage_PreservesOriginalJpegExtension): 🛠️ BUILD ERROR · 34s
Error-relevant lines (filtered from the build log):
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:224) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Download.downloadInternal(Download.java:109) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Download.download(Download.java:89) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install.forceFetch(Install.java:171) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install.fetchDistribution(Install.java:104) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install.access$400(Install.java:46) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install$1.call(Install.java:81) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install$1.call(Install.java:68) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.ExclusiveFileAccessManager.access(ExclusiveFileAccessManager.java:69) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install.createDist(Install.java:68) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.WrapperExecutor.execute(WrapperExecutor.java:109) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.GradleWrapperMain.main(GradleWrapperMain.java:66) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
Build FAILED.
🟢 With fix — 📱 MediaPicker_Shared_Tests (ProcessImage_Downsizes_ToNewCacheFile, ProcessImage_PreservesJpegContainer, ProcessImage_PreservesOriginalJpegExtension): 🛠️ BUILD ERROR · 7s
Error-relevant lines (filtered from the build log):
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:224) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Download.downloadInternal(Download.java:109) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Download.download(Download.java:89) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install.forceFetch(Install.java:171) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install.fetchDistribution(Install.java:104) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install.access$400(Install.java:46) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install$1.call(Install.java:81) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install$1.call(Install.java:68) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.ExclusiveFileAccessManager.access(ExclusiveFileAccessManager.java:69) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install.createDist(Install.java:68) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.WrapperExecutor.execute(WrapperExecutor.java:109) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.GradleWrapperMain.main(GradleWrapperMain.java:66) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
Build FAILED.
🔴 Without fix — 📱 Android_MediaPicker_Tests (ProcessPhoto_Rotate_DoesNotModifyOrDeleteSource, ProcessPhoto_PreservesExifMetadata): 🛠️ BUILD ERROR · 8s
Error-relevant lines (filtered from the build log):
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:224) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Download.downloadInternal(Download.java:109) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Download.download(Download.java:89) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install.forceFetch(Install.java:171) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install.fetchDistribution(Install.java:104) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install.access$400(Install.java:46) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install$1.call(Install.java:81) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install$1.call(Install.java:68) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.ExclusiveFileAccessManager.access(ExclusiveFileAccessManager.java:69) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install.createDist(Install.java:68) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.WrapperExecutor.execute(WrapperExecutor.java:109) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.GradleWrapperMain.main(GradleWrapperMain.java:66) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
Build FAILED.
🟢 With fix — 📱 Android_MediaPicker_Tests (ProcessPhoto_Rotate_DoesNotModifyOrDeleteSource, ProcessPhoto_PreservesExifMetadata): 🛠️ BUILD ERROR · 6s
Error-relevant lines (filtered from the build log):
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:224) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Download.downloadInternal(Download.java:109) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Download.download(Download.java:89) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install.forceFetch(Install.java:171) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install.fetchDistribution(Install.java:104) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install.access$400(Install.java:46) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install$1.call(Install.java:81) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install$1.call(Install.java:68) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.ExclusiveFileAccessManager.access(ExclusiveFileAccessManager.java:69) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.Install.createDist(Install.java:68) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.WrapperExecutor.execute(WrapperExecutor.java:109) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error XAGRDL0000: at org.gradle.wrapper.GradleWrapperMain.main(GradleWrapperMain.java:66) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net11.0-android37.0]
Build FAILED.
⚠️ Failure Details
- 🛠️ MediaPicker_Shared_Tests (ProcessImage_Downsizes_ToNewCacheFile, ProcessImage_PreservesJpegContainer, ProcessImage_PreservesOriginalJpegExtension) without fix: build failed before tests could run
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error MSB6006: "gradlew" exited with code 1. [/home/vsts/w...
- 🛠️ Android_MediaPicker_Tests (ProcessPhoto_Rotate_DoesNotModifyOrDeleteSource, ProcessPhoto_PreservesExifMetadata) without fix: build failed before tests could run
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error MSB6006: "gradlew" exited with code 1. [/home/vsts/w...
- 🛠️ MediaPicker_Shared_Tests (ProcessImage_Downsizes_ToNewCacheFile, ProcessImage_PreservesJpegContainer, ProcessImage_PreservesOriginalJpegExtension) with fix: build failed (fix does not compile)
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error MSB6006: "gradlew" exited with code 1. [/home/vsts/w...
- 🛠️ Android_MediaPicker_Tests (ProcessPhoto_Rotate_DoesNotModifyOrDeleteSource, ProcessPhoto_PreservesExifMetadata) with fix: build failed (fix does not compile)
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/37.0.0-ci.main.2160/tools/Microsoft.Android.Sdk.Bindings.Gradle.targets(122,5): error MSB6006: "gradlew" exited with code 1. [/home/vsts/w...
📁 Fix files reverted (35 files)
src/Essentials/src/MediaPicker/ImageProcessor.android.cssrc/Essentials/src/MediaPicker/ImageProcessor.ios.cssrc/Essentials/src/MediaPicker/ImageProcessor.netstandard.cssrc/Essentials/src/MediaPicker/ImageProcessor.shared.cssrc/Essentials/src/MediaPicker/ImageProcessor.windows.cssrc/Essentials/src/MediaPicker/MediaPicker.android.cssrc/Essentials/src/MediaPicker/MediaPicker.ios.cssrc/Essentials/src/MediaPicker/MediaPicker.shared.cssrc/Essentials/src/MediaPicker/MediaPicker.windows.cssrc/Graphics/src/Graphics.Skia/PublicAPI/net-android/PublicAPI.Unshipped.txtsrc/Graphics/src/Graphics.Skia/PublicAPI/net-ios/PublicAPI.Unshipped.txtsrc/Graphics/src/Graphics.Skia/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txtsrc/Graphics/src/Graphics.Skia/PublicAPI/net-macos/PublicAPI.Unshipped.txtsrc/Graphics/src/Graphics.Skia/PublicAPI/net-windows/PublicAPI.Unshipped.txtsrc/Graphics/src/Graphics.Skia/PublicAPI/net/PublicAPI.Unshipped.txtsrc/Graphics/src/Graphics.Skia/PublicAPI/netstandard/PublicAPI.Unshipped.txtsrc/Graphics/src/Graphics.Skia/SkiaImage.cssrc/Graphics/src/Graphics.Skia/SkiaImageLoadingService.cssrc/Graphics/src/Graphics/IImage.cssrc/Graphics/src/Graphics/IImageLoadingService.cssrc/Graphics/src/Graphics/PlatformImage.cssrc/Graphics/src/Graphics/Platforms/Android/PlatformImage.cssrc/Graphics/src/Graphics/Platforms/Android/PlatformImageLoadingService.cssrc/Graphics/src/Graphics/Platforms/Mac/PlatformImage.cssrc/Graphics/src/Graphics/Platforms/MaciOS/PlatformImageLoadingService.cssrc/Graphics/src/Graphics/Platforms/Windows/PlatformImage.cssrc/Graphics/src/Graphics/Platforms/Windows/PlatformImageLoadingService.cssrc/Graphics/src/Graphics/Platforms/iOS/PlatformImage.cssrc/Graphics/src/Graphics/PublicAPI/net-android/PublicAPI.Unshipped.txtsrc/Graphics/src/Graphics/PublicAPI/net-ios/PublicAPI.Unshipped.txtsrc/Graphics/src/Graphics/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txtsrc/Graphics/src/Graphics/PublicAPI/net-macos/PublicAPI.Unshipped.txtsrc/Graphics/src/Graphics/PublicAPI/net-windows/PublicAPI.Unshipped.txtsrc/Graphics/src/Graphics/PublicAPI/net/PublicAPI.Unshipped.txtsrc/Graphics/src/Graphics/PublicAPI/netstandard/PublicAPI.Unshipped.txt
New files (not reverted):
src/Graphics/src/Graphics/IImageMetadata.cssrc/Graphics/src/Graphics/ImageLoadOptions.cssrc/Graphics/src/Graphics/ImageSaveOptions.cssrc/Graphics/src/Graphics/Platforms/Android/AndroidImageMetadata.cssrc/Graphics/src/Graphics/Platforms/Windows/WindowsImageMetadata.cssrc/Graphics/src/Graphics/Platforms/iOS/AppleImageMetadata.cs
📋 Pre-Flight — Context & Validation
PR #36581 Pre-Flight
Issue: #33258 - [Android] picked images end up with unexpected _processed suffix
PR: #36581 - [net11] Image metadata API in Graphics; stop Android MediaPicker mutating picked source files
Base: net11.0 at merge base 87f2c23edba78fa29ad22a20408dea99712cceea
Materialized PR commit: 313a335ab98078e5970519c8c7a3c04a3173214d
Platform for STEP 5a: Android
Problem and Expected Behavior
Android MediaPicker processing currently appends _processed for compression and _rotated for rotation. More importantly, its in-place delete/rewrite path can mutate or lose a picked source that the application does not own. Processing must leave the source bytes and path untouched, return a distinct app-cache file when processing is needed, preserve the original filename, honor rotation/compression options, and preserve requested EXIF metadata.
PR Scope
The PR changes 43 files: 27 implementation files, 2 device-test files, and 14 PublicAPI records. It replaces platform-specific MediaPicker processors with a shared MAUI Graphics processor and adds public metadata/load/save APIs across Graphics, Skia, Android, Apple, and Windows.
The directly inspected Android path is:
src/Essentials/src/MediaPicker/MediaPicker.android.cssrc/Essentials/src/MediaPicker/ImageProcessor.android.cs(deleted by the PR)src/Essentials/src/MediaPicker/ImageProcessor.shared.cssrc/Graphics/src/Graphics/Platforms/Android/PlatformImage.cssrc/Graphics/src/Graphics/Platforms/Android/AndroidImageMetadata.cs
The PR tests are:
src/Essentials/test/DeviceTests/Tests/Android/MediaPicker_Tests.cssrc/Essentials/test/DeviceTests/Tests/MediaPicker_Shared_Tests.cs
Existing PR Approach and Review Context
The current fix introduces a cross-platform Graphics metadata pipeline and routes MediaPicker through it. Any STEP 5a candidate must use a different root-cause strategy rather than reproduce that architecture.
Current-head feedback relevant to alternative design includes:
- The new same-arity
IImage/IImageLoadingServiceoverloads can be source-breaking for calls usingdefault. - A concrete Windows loading-service call may not compile because its options overload is explicit-interface-only.
- PNG compression below quality 90 no longer converts to JPEG as documented.
- Android metadata save paths ignore bitmap-compression failure and use best-effort cleanup patterns.
- The PR's broad public API and multi-platform blast radius substantially exceeds the Android issue.
No recent labeled bug-fix overlap was found by the completed regression-risk phase.
Test Contract
Use only:
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Essentials -Platform android -TestFilter "Category=MediaPicker"This category contains the detected primary and regression coverage:
ProcessPhoto_Compress_DoesNotModifyOrDeleteSourceProcessPhoto_Rotate_DoesNotModifyOrDeleteSourceProcessPhoto_PreservesExifMetadataProcessImage_Downsizes_ToNewCacheFileProcessImage_PreservesJpegContainerProcessImage_PreservesOriginalJpegExtension
The completed gate is INCONCLUSIVE. Both baseline and PR builds stopped before tests because Gradle distribution retrieval failed (XAGRDL0000/MSB6006); this is not evidence that either implementation is behaviorally wrong. Do not rerun the gate or overwrite gate/content.md.
STEP 5a Constraints
- One implementation/test pass per candidate, with at most one focused correction and retest.
- Preserve the PR tests as the behavioral contract; do not weaken or delete them.
- Restore through
EstablishBrokenBaseline.ps1 -Restore. - The dedicated expert review is deferred to STEP 5b; no separate reviewer is launched here.
🔬 Code Review — Deep Analysis
Expert PR Evaluation
Verdict: NEEDS_CHANGES
Confidence: Medium-high
The submitted PR has a sound architectural direction: it centralizes MediaPicker image processing on MAUI Graphics, avoids mutating picked source files, preserves metadata across transforms, and makes output-format selection deterministic. The expert review nevertheless found two major correctness/API concerns and several material cross-platform regressions. The inconclusive Gate result was not treated as a failure and did not determine this verdict.
Blocking findings
- The new
IImageandIImageLoadingServicemembers break externalnetstandard2.0implementers. The modern targets use default interface implementations, but theNETSTANDARD2_0declarations are required abstract members. Existing third-party implementations can fail to compile or load. The API needs an opt-in compatibility shape or an explicit decision that this break is acceptable. - The default MediaPicker path can return sideways images.
DisableRotationNormalization = !options.RotateImageleaves pixels unrotated whenRotateImageis false. If metadata is not preserved, cannot be re-embedded, or fails to save, the EXIF orientation is lost and portrait images remain rotated. Orientation should be normalized whenever the output cannot reliably retain its orientation tag, or the behavioral contract must be explicitly reconsidered.
Additional actionable findings
- iOS can return JPEG bytes and
image/jpegwhile preserving a.HEIC/.HEIFFileName. - Android no longer cleans up MAUI-owned temporary inputs, causing full-size cache duplicates to accumulate.
- Android's processing-failure fallback can rename arbitrary source bytes with a
.jpgextension and duplicates the source again. - Android treats metadata-write failures as total save failures, unlike the graceful iOS and Windows fallback behavior.
- Android's existing
FromStream(Stream, ImageFormat)path now throws for undecodable streams instead of producing the previous blank image behavior. - Windows metadata preservation performs a second lossy JPEG encode and increases peak memory.
- iOS
SaveAsyncperforms encoding and copying synchronously and can block picker continuations on the main thread. SkiaImageLoadingServicesilently ignores the new load options.- Android metadata orientation remains correct only because
Downsizecurrently does not bake orientation; that invariant is implicit.
Blast radius and failure modes
The blast radius extends beyond MediaPicker to the public Graphics interfaces, Controls image loading, Skia, platform image codecs, and external implementations. The highest-risk ordinary cases are a default RotateImage = false pick with PreserveMetaData = false, HEIC input on iOS, repeated Android captures that leave cache copies, and metadata-save errors that should not invalidate otherwise valid pixel output.
The review produced 12 inline findings in inline-findings.json. Reviewer feedback can materially improve the PR, particularly by preserving netstandard2.0 compatibility and ensuring output orientation does not depend on metadata that may be intentionally dropped.
🛠️ Try-Fix — Analysis & Comparison
Try-Fix Aggregate — PR #36581
Alternative-fix exploration for issue #33258 (Android MediaPicker mutating/deleting the picked source).
Test contract: pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Essentials -Platform android -TestFilter "Category=MediaPicker".
Candidate 1 — Android-local copy-on-write output
Result: ✅ Pass — Tests run: 362 Passed: 362 Inconclusive: 0 Failed: 0 on emulator-5554 (API 30, x86_64), XHarness exit code 0.
Approach: Treat the picked source as strictly read-only. MediaPickerImplementation.ProcessImage
(Android) opens it with File.OpenRead, composes rotation + resize/compression in memory through the
existing platform ImageProcessor partials (RotateImageAsync, ExtractMetadataAsync,
ApplyMetadataAsync, ProcessImageAsync), and hands the final stream to a new internal shared helper
ImageProcessor.ProcessImageToCacheFileAsync, which writes it once to a freshly allocated MAUI-owned
cache file (Android: FileSystemUtils.GetTemporaryFile) preserving the picked file name verbatim. The
preserveSource parameter and the File.Delete(imagePath) branch are removed outright, so no
MediaPicker path can rewrite, rename or delete the resolved source. Extension handling is deterministic
container sniffing: .jpeg stays .jpeg; the extension is swapped only when the container genuinely
changed (PNG -> JPEG). No _processed / _rotated suffix can be produced.
Root-cause difference vs the PR: the PR hypothesizes that MAUI Graphics cannot load/save with
metadata, so the per-platform processors are deleted and replaced by a new cross-platform Graphics
metadata pipeline (43 files; new public IImageMetadata, ImageLoadOptions, ImageSaveOptions,
IImage/IImageLoadingService overloads; 14 PublicAPI records). This candidate hypothesizes that the
source is mutated because the Android MediaPicker chose the source path as its own output target —
a bug in ownership of the output location, not in the imaging stack. It keeps every per-platform
processor, adds zero public API, and changes 2 files.
Files changed:
src/Essentials/src/MediaPicker/ImageProcessor.shared.cssrc/Essentials/src/MediaPicker/MediaPicker.android.cs
Iterations: implementation pass + ONE focused correction.
Iteration 1 failed one pre-existing regression test
(MediaPickerRecovery_Tests.ProcessPhotoPreservingSource_RotationAndCompression_Deletes_Rotated_Intermediate,
Assert.Single() Failure: The collection was empty) because the output was written to
FileSystem.CacheDirectory/<guid>/ and therefore was not an Essentials-owned temporary file. The
correction added an optional Func<string,string>? outputPathProvider hook and routed the Android
write through FileSystemUtils.GetTemporaryFile. Retest: 362/362 passed.
Self-review: 2 findings, both minor, 0 critical / 0 major (inconclusive-sniff extension
fallback; per-call delegate instead of a platform partial method). The pre-test moderate finding about
MAUI-owned-temp tagging was empirically confirmed by iteration 1 and resolved by the correction.
Notes: EstablishBrokenBaseline.ps1 could not establish the baseline (fail-fast on a worktree
already dirty with ~40 orchestration-owned .github/eng files this candidate must not touch, and the
script has no bypass). The baseline was materialized instead by writing merge-base (87f2c23edb)
content for exactly the 41 PR implementation files under src/, with the PR test files left untouched
as the behavioral contract. EstablishBrokenBaseline.ps1 -Restore was run at the end. No test was
weakened, renamed, deleted or modified.
Full narrative: PRAgent/try-fix-1/content.md
Artifacts: PRAgent/try-fix/attempt-1/ (baseline.log, approach.md, reviewer-findings.json, reviewer-findings.diff, result.txt, fix.diff, test-output.log, analysis.md)
Candidate 2 — Owned-staging sandbox
Result: EstablishBrokenBaseline.ps1 invocation rejected the existing orchestration-dirty worktree before implementation.
Approach: Copy the picked source into a unique Essentials-owned temporary staging location, retain the existing destructive Android rotation/compression pipeline but restrict it to that owned staging copy, then publish exactly one MAUI-owned temporary result with the original picked filename and clean all staging/intermediate files. This differs materially from Candidate 1's read-only in-memory composition/shared cache-writer/delegate strategy and from the PR's Graphics public-API redesign.
Files changed: None. The exact candidate diff is empty.
Test: Not run. The baseline guard failed first; no gate, Candidate 1 test, or other command was run.
Self-review: 0 findings; reviewer-findings.json is [] and corresponds to the empty candidate diff.
Failure analysis: This is an environment/baseline Blocked result, not evidence that the owned-staging approach works or fails. The worktree contained out-of-scope orchestration-owned .github/eng changes and regenerated HybridWebView.js; the candidate did not alter or bypass them. EstablishBrokenBaseline.ps1 -Restore was run and reported that no baseline state existed.
Full narrative: PRAgent/try-fix-2/content.md
Artifacts: PRAgent/try-fix/attempt-2/ (baseline.log, approach.md, reviewer-findings.json, reviewer-findings.diff, result.txt, fix.diff, test-output.log, analysis.md)
📝 PR Finalize — Recommended Title & Description
Assessment: ✏️ Recommend updating — the current description is thorough, but it incorrectly claims all implementers remain source-compatible and overstates RotateImage = false behavior when orientation metadata is not retained.
Recommended title
[Android] MediaPicker / Graphics: Stop source mutation and add image metadata APIs (net11)
Recommended description
### Description of Change
This PR adds an image-metadata (EXIF) pipeline to **MAUI Graphics** and moves **`MediaPicker`** image processing onto it, fixing an Android file-ownership bug along the way. It targets **net11.0**.
### Root cause
Android MediaPicker previously rotated or compressed a picked image by deleting and rewriting its resolved source path. Picked files can resolve to locations the app does not own, including an SD card, another app's shared storage, or the user's original gallery item. Mutating that path can corrupt external data, and deleting before writing can lose the original if the write fails.
**1. Image metadata API in MAUI Graphics**
Graphics can now load an image while capturing its metadata (EXIF), preserve that metadata through resize/rotate transforms, and re-embed it when saving. Callers also get explicit control over EXIF orientation normalization.
The capability is added as new members on the existing `IImage` and `IImageLoadingService` interfaces, following the repository's established pattern for foundational interfaces (see `Microsoft.Maui.IPicker`): default interface members on modern targets and abstract members on `netstandard2.0`, which has no DIM support. The modern-target defaults reproduce current behavior—orientation normalized and metadata dropped—so existing consumers and modern-target implementers remain source-compatible. **External `netstandard2.0` implementers must add the new abstract members when recompiling; this is a source compatibility break that requires explicit API compatibility sign-off.**
Platform coverage:
| Platform | Capture + orientation | Re-embed on save |
| --- | --- | --- |
| Android | ✅ | JPEG |
| iOS / MacCatalyst | ✅ | JPEG + PNG |
| Windows | ✅ | JPEG |
| macOS | pixel-only save (no metadata) | — |
Windows needs special handling because Win2D's `CanvasBitmap.LoadAsync` ignores EXIF orientation: the options-based load path normalizes orientation via `BitmapDecoder`/`SoftwareBitmap` and re-embeds metadata by transcoding through `BitmapEncoder`.
**2. `MediaPicker` shares one Graphics-based processor**
All platforms now process picked/captured photos through one shared, Graphics-based `ImageProcessor`, replacing the previous per-platform implementations. Processing only reads the source and writes the result to a new app-cache file. It preserves the original base name and preserves the extension when the output remains in the same container (#33258).
Behavior is now consistent and deterministic across platforms:
- The source file is never modified or deleted.
- `RotateImage = false` disables pixel-orientation normalization and relies on retained EXIF orientation. If metadata preservation is disabled or the output cannot re-embed orientation metadata, the encoded pixels are not made upright.
- Output container is deterministic: PNG stays PNG, and everything else is JPEG (no more `quality ≥ 95 → PNG` switching).
- EXIF metadata is preserved through rotation when the platform/output combination supports re-embedding it.
- On iOS, camera captures are processed in memory through Graphics (no temp file and no bespoke UIKit resizing).
Quality is `0.0–1.0` throughout the Graphics layer; `MediaPickerOptions.CompressionQuality` remains `0–100` (the Essentials convention) and is converted at the boundary.
### Key implementation areas
- `src/Graphics/src/Graphics/IImage.cs` and `IImageLoadingService.cs` define the new options-based public contracts.
- `src/Graphics/src/Graphics/Platforms/{Android,iOS,Windows}/` captures orientation/metadata and re-embeds supported metadata on save.
- `src/Essentials/src/MediaPicker/ImageProcessor.shared.cs` contains the shared MediaPicker processing pipeline.
- Platform MediaPicker files now provide only source/output plumbing around that shared processor.
### New public API
All new public API lives in **`Microsoft.Maui.Graphics`**. Essentials adds no public API; its `MediaPicker` processor is internal.
**New types**
```csharp
namespace Microsoft.Maui.Graphics;
// Opaque, platform-specific metadata (EXIF/GPS/…) captured at load time.
// Intentionally has no members: it exists to be carried through a
// load → transform → save round trip, not to be inspected by callers.
public interface IImageMetadata
{
}
// Controls how an image is decoded. Plain options class, used with an object initializer,
// consistent with MediaPickerOptions / BrowserLaunchOptions / etc.
public class ImageLoadOptions
{
// false (default) => bake the source EXIF orientation into the pixels (upright).
// true => return pixels exactly as decoded; orientation stays in metadata only.
public bool DisableRotationNormalization { get; set; }
// Capture metadata during load so it can be re-embedded when saving.
public bool PreserveMetadata { get; set; }
}
// Controls how an image is encoded.
public class ImageSaveOptions
{
public float Quality { get; set; } = 1f; // 0.0–1.0, used by lossy formats (JPEG)
public bool PreserveMetadata { get; set; } // re-embed metadata captured at load time
}
```
**New members on the existing interfaces** (default interface members on modern targets; abstract on `netstandard2.0`)
```csharp
namespace Microsoft.Maui.Graphics;
public interface IImage // additions only
{
// null if the image was not loaded with ImageLoadOptions.PreserveMetadata (or has none).
IImageMetadata? Metadata { get; }
// Save overloads that honor ImageSaveOptions (quality + optional metadata re-embed).
void Save(Stream stream, ImageFormat format, ImageSaveOptions options);
Task SaveAsync(Stream stream, ImageFormat format, ImageSaveOptions options);
}
public interface IImageLoadingService // additions only
{
// Load with orientation-normalization / metadata-capture control.
IImage FromStream(Stream stream, ImageLoadOptions options);
}
```
**Concrete platform surface** (the same members surfaced on the platform types)
```csharp
namespace Microsoft.Maui.Graphics.Platform;
public partial class PlatformImage // Android / iOS / MacCatalyst / macOS / Windows
{
// The options-based loader. Mirrors the existing public
// static FromStream(Stream, ImageFormat) overload; the loading service forwards to it.
public static IImage FromStream(Stream stream, ImageLoadOptions options);
// IImage additions (public on Android/iOS/Mac; explicit-interface on Windows).
IImageMetadata? Metadata { get; }
void Save(Stream stream, ImageFormat format, ImageSaveOptions options);
Task SaveAsync(Stream stream, ImageFormat format, ImageSaveOptions options);
}
public partial class PlatformImageLoadingService
{
public IImage FromStream(Stream stream, ImageLoadOptions options);
}
```
### Platform notes and limitations
- **Graphics.Skia** surfaces the new interface members on `SkiaImage` / `SkiaImageLoadingService`, but it has no metadata backend: `SkiaImage.Metadata` is always `null`, and its loading service does not honor metadata/orientation options.
- Android and Windows re-embed metadata only for JPEG; iOS and MacCatalyst support JPEG and PNG.
- Tizen is not part of the build, so there is no Tizen-specific implementation; it inherits the default interface members.
### Issues Fixed
Fixes #33258.
### Tested the behavior in the following platforms
- [x] Android
- [ ] iOS
- [ ] Windows
- [x] MacCatalyst
Local device-test runs (net11 preview.6 SDK + platform workloads):
- **Android** (API 30 emulator): the full Essentials device-test suite runs; all MediaPicker/image tests pass, including the file-ownership tests (`ProcessPhoto_Compress/Rotate_DoesNotModifyOrDeleteSource`), EXIF preservation, and shared `ImageProcessor` tests. The only failures are pre-existing `Launcher.CanOpen` tests that need a browser/mail/phone app unavailable on the bare emulator.
- **MacCatalyst**: the full Essentials device-test suite passes with 0 failures, including the shared MediaPicker tests. MacCatalyst compiles the same Apple code as iOS (the `Platforms/iOS` Graphics sources and `MediaPicker.ios.cs`), so this exercises the Apple metadata capture/orientation/save path.
- **iOS simulator**: could not be built locally because the .NET iOS 26.5 workload's asset compiler requires a ≥ 26.5 simulator runtime and only ≤ 26.3 is installed. This is unrelated to the change. iOS uses the same Apple code exercised on MacCatalyst and runs on CI `maui-pr-devicetests`.
- **Windows**: builds and runs on CI (`maui-pr` and `maui-pr-devicetests`).
A shared cross-platform test covers the default `RotateImage = false` + resize + `PreserveMetadata` path.
🏁 Report — Final Recommendation
⚠️ Final Recommendation: REQUEST CHANGES
Winner: try-fix-1 — Android-local copy-on-write output.
try-fix-1 is the strongest candidate because it fixes the actual ownership bug inside Essentials, preserves the existing platform metadata pipeline, adds no public API, changes only two internal files, and passed the complete Android MediaPicker category after its single allowed correction. The submitted PR should not be approved as-is because both the raw PR and pr-plus-reviewer retain an unresolved public-interface compatibility concern; selecting an alternative candidate necessarily requires changes to the submitted PR.
Comparative ranking
| Rank | Candidate | Validation | Scope | Assessment |
|---|---|---|---|---|
| 1 | try-fix-1 |
PASS — 362/362 | 2 internal Essentials files | Fixes the source-mutation root cause with copy-on-write output, preserves established platform behavior, and has only two accepted minor self-review findings. |
| 2 | pr-plus-reviewer |
PASS — 363/363 | Raw PR's 43-file cross-platform/API change plus 4-file reviewer patch | Fixes the demonstrated orientation-loss case, Android fallback naming, and iOS HEIC/JPEG naming mismatch, but retains the NETSTANDARD2_0 interface break and other cross-platform risks. |
| 3 | pr |
INCONCLUSIVE Gate | 43 files and 14 PublicAPI records | Sound architectural direction and source-file ownership fix, but expert review found two major concerns plus material platform regressions. The Gate was blocked by Gradle retrieval and is not counted as a test failure. |
| 4 | try-fix-2 |
BLOCKED — not run | No implementation/diff | The owned-staging idea is plausible but could not be materialized because the baseline guard rejected the dirty orchestration worktree; there is no code or empirical evidence to rank above implemented candidates. |
Why try-fix-1 wins
The defect is that Android MediaPicker used a resolved picked path as a destructive output/scratch location. try-fix-1 corrects that ownership boundary directly: it opens the source read-only, composes the existing rotation/compression/metadata operations in memory, and writes once to a new MAUI-owned temporary file while preserving the picked name. This retains the existing per-platform processors and avoids coupling an Android file-ownership fix to a new Graphics metadata API across every platform.
Its final validation covered all six PR contract tests and the existing MediaPicker/MediaPickerRecovery regression suite. The first run exposed that outputs must use FileSystemUtils.GetTemporaryFile; the one allowed correction restored that ownership marker, and the final run passed 362/362. The two remaining self-review observations are minor and do not describe reachable current defects.
Submitted PR and reviewer refinement
The raw PR correctly stops modifying picked source files and provides a coherent metadata pipeline. However, the expert review identified required members added to shipped NETSTANDARD2_0 interfaces and a concrete orientation-loss path when normalization is disabled but metadata is dropped. It also found iOS filename/content disagreement, Android cache and failure-path concerns, Android metadata-save asymmetry, a changed invalid-stream behavior, Windows double encoding, synchronous iOS save work, and ignored Skia options.
pr-plus-reviewer repaired the concrete orientation-loss path for outputs that cannot carry orientation metadata, fixed the Android fallback container/name mismatch and iOS HEIC filename mismatch, and added a passing regression test. That candidate passed 363/363 focused tests, so it ranks above the raw PR. It still does not safely resolve the public NETSTANDARD2_0 interface compatibility problem within the permitted one-pass refinement, and its broad blast radius remains unnecessary for issue #33258.
Decision
Adopt try-fix-1. It has the narrowest justified scope, the best compatibility profile, and passing regression evidence. The raw PR's separate Graphics metadata API can be reconsidered independently with an API shape that explicitly addresses external netstandard2.0 implementers and with platform-specific correctness/performance coverage.
📱 UI Tests — Button,Label,Layout
Detected UI test categories: Button,Label,Layout
✅ Deep UI tests — 360 passed, 0 failed, 7 skipped across 3 categories on platform-pool agent (replaces in-process counts above).
🧪 UI Test Execution Results (deep, platform pool)
| Category | Tests | Snapshot diffs |
|---|---|---|
Button |
71/73 (2 skipped) ✓ | — |
Label |
97/99 (2 skipped) ✓ | — |
Layout |
192/195 (3 skipped) ✓ | — |
📎 Download drop-deep-uitests artifact (TRX + snapshot diffs) |
🧭 Next Steps — alternative fix proposed (try-fix-1)
Automated review — alternative fix proposed
The expert-reviewer evaluation compared the PR fix against automatically generated candidates and selected try-fix-1 as the strongest fix.
Why: try-fix-1 directly fixes Android MediaPicker source ownership with a two-file internal copy-on-write change and passed all 362 targeted regression tests. It avoids the submitted PR’s unresolved netstandard2.0 interface compatibility break and broad cross-platform API blast radius.
Please consider applying the candidate diff below (or use it as guidance). Once you push an update, this workflow will re-trigger and re-evaluate.
Candidate diff (try-fix-1)
diff --git a/src/Essentials/src/MediaPicker/ImageProcessor.shared.cs b/src/Essentials/src/MediaPicker/ImageProcessor.shared.cs
index a205cf6ddb..1a802c951b 100644
--- a/src/Essentials/src/MediaPicker/ImageProcessor.shared.cs
+++ b/src/Essentials/src/MediaPicker/ImageProcessor.shared.cs
@@ -3,6 +3,7 @@ using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Maui.Media;
+using Microsoft.Maui.Storage;
#if IOS || ANDROID || WINDOWS
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Graphics.Platform;
@@ -10,6 +11,36 @@ using Microsoft.Maui.Graphics.Platform;
namespace Microsoft.Maui.Essentials;
+/// <summary>
+/// The resolved set of image transformations the MediaPicker applies to a picked or captured photo.
+/// </summary>
+internal readonly struct ImageProcessingOptions
+{
+ public ImageProcessingOptions(
+ int? maximumWidth,
+ int? maximumHeight,
+ int compressionQuality,
+ bool rotateImage,
+ bool preserveMetadata)
+ {
+ MaximumWidth = maximumWidth;
+ MaximumHeight = maximumHeight;
+ CompressionQuality = compressionQuality;
+ RotateImage = rotateImage;
+ PreserveMetadata = preserveMetadata;
+ }
+
+ public int? MaximumWidth { get; }
+
+ public int? MaximumHeight { get; }
+
+ public int CompressionQuality { get; }
+
+ public bool RotateImage { get; }
+
+ public bool PreserveMetadata { get; }
+}
+
/// <summary>
/// Unified image processing helper using MAUI Graphics for cross-platform consistency.
/// </summary>
@@ -259,7 +290,6 @@ internal static partial class ImageProcessor
#endif
}
-#if IOS || ANDROID || WINDOWS
/// <summary>
/// Detects image format from stream using magic numbers.
/// </summary>
@@ -301,6 +331,161 @@ internal static partial class ImageProcessor
imageData.Position = originalPosition;
}
}
-#endif
+
+ /// <summary>
+ /// Processes <paramref name="input"/> and writes the result to a brand new file in the app cache
+ /// directory, preserving the original file name. The input is only ever read - the caller's source
+ /// file is never modified, renamed or deleted (issue #33258).
+ /// </summary>
+ /// <param name="input">A readable stream over the picked source file.</param>
+ /// <param name="originalFileName">The picked file name, whose name and extension are preserved.</param>
+ /// <param name="options">The transformations to apply.</param>
+ /// <param name="outputPathProvider">
+ /// Optional platform hook that maps the output file name to the path to write. Platforms that
+ /// track ownership of their temporary files (Android's <c>FileSystemUtils.GetTemporaryFile</c>)
+ /// supply it so the result is tagged as a MAUI-owned temporary file; when omitted the result is
+ /// written to a unique sub-directory of the app cache.
+ /// </param>
+ /// <returns>The path of the newly written cache file.</returns>
+ public static async Task<string> ProcessImageToCacheFileAsync(Stream input, string? originalFileName, ImageProcessingOptions options, Func<string, string>? outputPathProvider = null)
+ {
+ if (input is null)
+ {
+ throw new ArgumentNullException(nameof(input));
+ }
+
+ if (input.CanSeek)
+ {
+ input.Position = 0;
+ }
+
+ var fileName = string.IsNullOrEmpty(originalFileName)
+ ? Guid.NewGuid().ToString("N") + ".jpg"
+ : Path.GetFileName(originalFileName!);
+
+ // Everything below composes new streams; `input` is never disposed here - it belongs to the caller.
+ var current = input;
+ Stream? owned = null;
+ try
+ {
+ if (options.RotateImage)
+ {
+ owned = await RotateImageAsync(current, fileName);
+ current = owned;
+ if (current.CanSeek)
+ {
+ current.Position = 0;
+ }
+ }
+
+ if (IsProcessingNeeded(options.MaximumWidth, options.MaximumHeight, options.CompressionQuality))
+ {
+ var processed = await ProcessImageAsync(
+ current,
+ options.MaximumWidth,
+ options.MaximumHeight,
+ options.CompressionQuality,
+ fileName,
+ false, // rotation, if any, has already been applied above
+ options.PreserveMetadata);
+
+ if (processed is not null)
+ {
+ owned?.Dispose();
+ owned = processed;
+ current = processed;
+ }
+ }
+
+ if (current.CanSeek)
+ {
+ current.Position = 0;
+ }
+
+ return await WriteToCacheFileAsync(current, GetOutputFileName(current, fileName, options.CompressionQuality), outputPathProvider);
+ }
+ finally
+ {
+ owned?.Dispose();
+ }
+ }
+
+ /// <summary>
+ /// Writes <paramref name="data"/> to a new file inside a unique sub-directory of the app cache, so
+ /// the picked file name can be preserved verbatim without ever colliding with another picked file.
+ /// </summary>
+ private static async Task<string> WriteToCacheFileAsync(Stream data, string fileName, Func<string, string>? outputPathProvider)
+ {
+ string? outputDirectory = null;
+ string outputPath;
+
+ if (outputPathProvider is not null)
+ {
+ outputPath = outputPathProvider(fileName);
+ }
+ else
+ {
+ outputDirectory = Path.Combine(FileSystem.CacheDirectory, Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(outputDirectory);
+ outputPath = Path.Combine(outputDirectory, fileName);
+ }
+
+ try
+ {
+ using (var output = File.Create(outputPath))
+ {
+ await data.CopyToAsync(output);
+ }
+ }
+ catch
+ {
+ // Never leave a half-written file (or its directory) behind.
+ try
+ {
+ if (outputDirectory is not null)
+ {
+ Directory.Delete(outputDirectory, recursive: true);
+ }
+ else
+ {
+ File.Delete(outputPath);
+ }
+ }
+ catch
+ {
+ // Best-effort cleanup; the cache directory is reclaimed by the OS.
+ }
+
+ throw;
+ }
+
+ return outputPath;
+ }
+
+ /// <summary>
+ /// Keeps the picked file name verbatim when the output container matches the source container -
+ /// a ".jpeg" source must stay ".jpeg" and not become ".jpg" (issue #33258). The extension is only
+ /// swapped when processing genuinely changed the container (e.g. PNG -> JPEG).
+ /// </summary>
+ private static string GetOutputFileName(Stream? data, string fileName, int qualityPercent)
+ {
+ var originalExtension = Path.GetExtension(fileName);
+ var detectedExtension = DetectImageFormat(data);
+
+ // Container unchanged (or undetectable, which means we did not re-encode into a new
+ // container): keep the picked name exactly as it was.
+ if (detectedExtension is null || IsSameContainer(originalExtension, detectedExtension))
+ {
+ return fileName;
+ }
+
+ return Path.ChangeExtension(fileName, detectedExtension);
+ }
+
+ private static bool IsSameContainer(string? extension, string otherExtension)
+ => string.Equals(NormalizeExtension(extension), NormalizeExtension(otherExtension), StringComparison.OrdinalIgnoreCase);
+
+ private static string NormalizeExtension(string? extension)
+ => string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase) ? ".jpg" : extension ?? string.Empty;
}
diff --git a/src/Essentials/src/MediaPicker/MediaPicker.android.cs b/src/Essentials/src/MediaPicker/MediaPicker.android.cs
index 2d7e765ab3..5d9cf041f4 100644
--- a/src/Essentials/src/MediaPicker/MediaPicker.android.cs
+++ b/src/Essentials/src/MediaPicker/MediaPicker.android.cs
@@ -28,11 +28,10 @@ namespace Microsoft.Maui.Media
internal static Task<string> ProcessPhotoAsync(string imagePath, MediaPickerOptions options)
=> ProcessImage(imagePath, options);
- // Recovery-sensitive MediaPicker paths must leave the original file intact until the
- // active recovery record has been cleared or promoted, so they opt out of the
- // MAUI-owned input cleanup that ProcessImage otherwise performs.
+ // Every MediaPicker path - recovery-sensitive or not - leaves the picked source untouched:
+ // ProcessImage only ever reads it and writes the result to a new MAUI-owned cache file.
internal static Task<string> ProcessPhotoPreservingSourceAsync(string imagePath, PersistedPhotoProcessingOptions options)
- => ProcessImage(imagePath, options, preserveSource: true);
+ => ProcessImage(imagePath, options);
internal static PersistedPhotoProcessingOptions GetPhotoProcessingOptions(MediaPickerOptions options)
=> new(
@@ -596,7 +595,7 @@ namespace Microsoft.Maui.Media
internal static Task<string> ProcessImage(string imagePath, MediaPickerOptions options)
=> ProcessImage(imagePath, GetPhotoProcessingOptions(options));
- internal static async Task<string> ProcessImage(string imagePath, PersistedPhotoProcessingOptions options, bool preserveSource = false)
+ internal static async Task<string> ProcessImage(string imagePath, PersistedPhotoProcessingOptions options)
{
if (string.IsNullOrEmpty(imagePath) || !File.Exists(imagePath))
return imagePath;
@@ -610,92 +609,32 @@ namespace Microsoft.Maui.Media
try
{
- var inputFileName = System.IO.Path.GetFileName(imagePath);
-
- // Compose the transforms without mutating the input: rotation and compression are
- // chained through in-memory streams and only the final result is written - once - to
- // a new MAUI-owned cache file (GetTemporaryFile preserves the original filename, see
- // #33258). External/user-owned sources (the gallery original, an SD card, another
- // app's storage) are therefore never deleted or overwritten. If the input was itself
- // a MAUI-owned temporary cache file (a camera capture, or a content:// URI that
- // EnsurePhysicalPath copied into our cache), it is removed after the output is written
- // (below) so we don't leave an orphaned duplicate behind - unless the caller opted
- // into preserving it because a recovery record still points at it.
- Stream currentStream = File.OpenRead(imagePath);
- try
- {
- if (needsRotation)
- {
- var rotatedStream = await ImageProcessor.RotateImageAsync(currentStream, inputFileName);
- currentStream.Dispose();
- currentStream = rotatedStream;
- currentStream.Position = 0;
- }
-
- if (needsProcessing)
- {
- var processedStream = await ImageProcessor.ProcessImageAsync(
- currentStream,
- options.MaximumWidth,
- options.MaximumHeight,
- options.CompressionQuality,
- inputFileName,
- false, // rotation, if any, has already been applied above
- options.PreserveMetaData);
-
- if (processedStream is not null)
- {
- currentStream.Dispose();
- currentStream = processedStream;
- currentStream.Position = 0;
- }
- }
-
- // Preserve the original filename (see #33258), but honor a format change
- // (e.g. PNG -> JPEG) by swapping the extension.
- var outputExtension = ImageProcessor.DetermineOutputExtension(currentStream, options.CompressionQuality, inputFileName);
- var originalExtension = System.IO.Path.GetExtension(inputFileName);
- var outputFileName = inputFileName;
- if (!string.Equals(outputExtension, originalExtension, StringComparison.OrdinalIgnoreCase))
- {
- outputFileName = System.IO.Path.ChangeExtension(inputFileName, outputExtension);
- }
-
- var outputFile = FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, outputFileName);
- var outputPath = outputFile.AbsolutePath;
-
- using (var outputStream = File.Create(outputPath))
- {
- currentStream.Position = 0;
- await currentStream.CopyToAsync(outputStream);
- }
-
- // The output is now fully written and closed. If the input was a MAUI-owned
- // temporary cache file, delete it so we don't accumulate an orphaned duplicate.
- // External/user-owned sources are never under our cache folder, so they are left
- // untouched. Deleting only after the output is written avoids any data-loss window.
- if (!preserveSource &&
- FileSystemUtils.IsMauiOwnedTemporaryFile(imagePath) &&
- !string.Equals(imagePath, outputPath, StringComparison.Ordinal))
- {
- try
- { File.Delete(imagePath); }
- catch { }
- }
-
- return outputPath;
- }
- finally
- {
- currentStream.Dispose();
- }
+ // Copy-on-write: the picked source is opened read-only, the transforms are composed in
+ // memory, and the result is written once to a brand new MAUI-owned cache file that keeps
+ // the original file name (#33258). The source - which may be a gallery original, an SD
+ // card file or another app's storage - is never rewritten, renamed or deleted, so no
+ // failure mode can lose or corrupt the user's photo.
+ using var source = File.OpenRead(imagePath);
+
+ return await ImageProcessor.ProcessImageToCacheFileAsync(
+ source,
+ System.IO.Path.GetFileName(imagePath),
+ new ImageProcessingOptions(
+ options.MaximumWidth,
+ options.MaximumHeight,
+ options.CompressionQuality,
+ needsRotation,
+ options.PreserveMetaData),
+ // Keep the result tagged as a MAUI-owned temporary file (and keep the picked
+ // file name) by allocating it through the same helper every other MediaPicker
+ // output uses.
+ outputFileName => FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, outputFileName).AbsolutePath);
}
catch
{
// If processing fails, leave the picked source untouched and return its path.
+ return imagePath;
}
-
- return imagePath;
}
}
}
Note
Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!
Description of Change
This PR adds an image-metadata (EXIF) pipeline to MAUI Graphics and moves the
MediaPickerimage processing onto it, fixing a file-ownership bug on Android along the way. Targets net11.0.1. Image metadata API in MAUI Graphics
Graphics can now load an image while capturing its metadata (EXIF), preserve that metadata through resize/rotate transforms, and re-embed it when saving. Callers also get explicit control over EXIF orientation normalization.
The capability is added as new members on the existing
IImageandIImageLoadingServiceinterfaces, following the repo's established pattern for foundational interfaces (seeMicrosoft.Maui.IPicker): default interface members on every target, and abstract members onnetstandard2.0(which has no DIM support). The default implementations reproduce the current behavior — orientation normalized, metadata dropped — so existing consumers and implementers are source-compatible.Platform coverage:
Windows needs special handling because Win2D's
CanvasBitmap.LoadAsyncignores EXIF orientation: the options-based load path normalizes orientation viaBitmapDecoder/SoftwareBitmapand re-embeds metadata by transcoding throughBitmapEncoder.2.
MediaPickershares one Graphics-based processorAll platforms now process picked/captured photos through a single shared, Graphics-based
ImageProcessor, replacing the previous per-platform implementations. Processing only ever reads the source and writes the result to a new app-cache file that preserves the original file name (#33258).This fixes a file-ownership bug on Android, which previously rotated/compressed the picked image with an in-place
File.Delete+ rewrite on the resolved source path. Picked files can resolve to locations the app doesn't own (an SD card, another app's shared storage, the user's original gallery item), so mutating them corrupts external data — and the delete-before-write risked losing the original on a failed write.Behavior is now consistent and deterministic across platforms:
RotateImage = falseno longer rotates.quality ≥ 95 → PNGswitching).Quality is
0.0–1.0throughout the Graphics layer;MediaPickerOptions.CompressionQualitystays0–100(Essentials convention) and is converted at the boundary.New public API
All new public API lives in
Microsoft.Maui.Graphics. Essentials adds no public API (theMediaPickerprocessor is internal).New types
New members on the existing interfaces (default interface members on all targets except
netstandard2.0, where they are abstract)Concrete platform surface (the same members surfaced on the platform types)
Notes
Issues Fixed
Fixes #33258.
Tested the behavior in the following platforms
Local device-test runs (net11 preview.6 SDK + platform workloads):
ProcessPhoto_Compress/Rotate_DoesNotModifyOrDeleteSource), EXIF preservation, and the sharedImageProcessortests. (The only failures are pre-existingLauncher.CanOpentests that need a browser/mail/phone app the bare emulator doesn't have.)Platforms/iOSGraphics sources andMediaPicker.ios.cs), so this exercises the Apple metadata capture / orientation / save path.maui-pr-devicetests.maui-pr+maui-pr-devicetests).A shared cross-platform test covers the default
RotateImage = false+ resize +PreserveMetadatapath.