[Android] MediaPicker: Stop overwriting picked source files - #36572
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 36572Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 36572" |
|
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 fixes Android MediaPicker post-processing (EXIF auto-rotation and compression/resizing) so that it no longer mutates the picked source file in place, avoiding potential user data loss and ensuring the processed output is written to a new MAUI-owned cache file.
Changes:
- Replaced in-place rotation with
RotateImagethat writes to a new cache file and returns the new path. - Updated compression/resizing (
CompressImageIfNeeded) to write to a new cache file (preserving the original filename; extension may change if format changes) without deleting/overwriting the source. - Added Android device tests verifying the source file remains byte-for-byte unchanged and the output is created under the app cache directory.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| src/Essentials/src/MediaPicker/MediaPicker.android.cs | Stops rotation/compression from overwriting/deleting the picked source by writing processed output to a new cache file and chaining returned paths. |
| src/Essentials/test/DeviceTests/Tests/Android/MediaPicker_Tests.cs | Adds device tests asserting the source image is unchanged and the processed image is written to the cache while preserving the original filename. |
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.
Expert Review — 1 findings
See inline comments for details.
When applying EXIF auto-rotation or compression/resizing to a picked image, the Android MediaPicker deleted and rewrote the file at the resolved source path. That path is not a file MAUI owns - it may be the user's original photo, a file on an SD card, or another app's shared storage - so mutating it in place is incorrect and destroys data the app shouldn't touch. It also deleted the original before writing the replacement, creating a data-loss window. Treat the picked source as read-only: write the rotated/compressed output to a new MAUI-owned cache file via GetTemporaryFile(CacheDir, originalFileName) and return that path. The original filename is preserved (keeps #33258 fixed). Adds Android device tests asserting the source is byte-for-byte unchanged, the output is a new file under the cache dir, and the original filename is kept. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 16028f2a-8a01-4921-9677-8485c551c0f0
Replace the separate RotateImage + CompressImageIfNeeded methods with a single ProcessImage that composes rotation and compression through in-memory streams from a read-only source and writes only the final result - once - to a MAUI-owned cache file. This keeps the source untouched (as before) and, for the rotate+compress case, avoids leaving an orphaned intermediate cache file behind. Rotation is applied explicitly before compression and the compression stage is told not to rotate again, instead of relying on the re-encode having stripped EXIF. Tests: cover rotation-only, compression-only, rotation+compression (asserting a single terminal cache file, i.e. no intermediate leak), and the no-op case (returns the original path, no cache copy). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 16028f2a-8a01-4921-9677-8485c551c0f0
ProcessImage always writes the processed image to a new MAUI-owned cache file and never touches the input. For external/user-owned sources (gallery, SD card, other apps) that is exactly right. But some inputs are themselves MAUI-owned temporary cache files - a camera capture from CapturePhotoAsync, or a content:// URI that EnsurePhysicalPath copied into our cache - and for those we were leaving the original full-size cache file orphaned alongside the new output. Add FileSystemUtils.IsMauiOwnedTemporaryFile, which recognises a file we created via GetTemporaryFile by its '<cache>/<EssentialsFolderHash>/' location (a folder only GetTemporaryFile ever creates, so external sources can never match it). ProcessImage now deletes the input only when it is one of these owned temporary files, and only after the output has been fully written - so external sources stay read-only and there is no data-loss window. Adds a device test asserting a MAUI-owned cache input is replaced by a single terminal cache file with no orphan left behind, with the original filename preserved (#33258). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 16028f2a-8a01-4921-9677-8485c551c0f0
bca7feb to
9b0d5d4
Compare
|
/azp run maui-pr, maui-pr-devicetests, maui-pr-uitests |
|
Azure Pipelines: Successfully started running 3 pipeline(s). |
This comment has been minimized.
This comment has been minimized.
kubaflo
left a comment
There was a problem hiding this comment.
Could you please check the ai's suggestions?
|
/review -b improved-reviewer -p android |
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 4 findings
See inline comments for details.
|
|
||
| // 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); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Logic and Correctness — Rotation-only picks can be written out under a wrong extension, a regression versus the old in-place behavior.
Concrete scenario: PickPhotoAsync(new MediaPickerOptions { RotateImage = true }) on a HEIC/WEBP/GIF/BMP source.
needsProcessingisfalse(defaultCompressionQuality = 100, no max dimensions), so onlyRotateImageAsyncruns.ImageProcessor.RotateImageAsyncreturnsnew MemoryStream(bytes)— the original, unmodified bytes — whenever EXIF orientation is1(the common case) or the bitmap fails to decode/compress (ImageProcessor.android.cslines 41, 49, 57, 95, 103).ImageProcessor.DetectImageFormatonly recognizes PNG and JPEG magic numbers (ImageProcessor.shared.csL285-295) and returnsnullfor those bytes.DetermineOutputExtensionthen falls through to the quality heuristicqualityPercent >= 95 ? ".png" : ".jpg"(L258). With quality 100 this yields".png", so line 659-661 renamesIMG_1234.heic→IMG_1234.pngand line 664-671 writes the HEIC bytes into a file named.png.
Previously RotateImageInPlace wrote back to the same path, so the extension always survived. Consumers that dispatch on FileResult.FileName/ContentType (or Image.Source = ImageSource.FromFile(...)) now get a mislabeled file. A .jpeg source is likewise silently renamed to .jpg.
Suggested fix: only change the extension when DetectImageFormat actually returned a format (i.e. when the bytes were genuinely re-encoded), and otherwise keep inputFileName unchanged.
| // 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 && |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Memory Leak Prevention / cache growth — Deleting only the input file leaves its containing directory behind forever.
FileSystemUtils.GetTemporaryFile creates <cache>/2203693cc04e0be7f4f024d5f9499e13/<new-guid>/<name> for every call (FileSystemUtils.android.cs L43-46). This PR now allocates one such GUID directory per processed image (line 664) in addition to the one the input already occupied, and the File.Delete(imagePath) here removes the file but not its parent GUID directory. Java.IO.File.DeleteOnExit() is effectively a no-op on Android (the process is killed, not exited normally), so these empty directories accumulate for the app's lifetime.
Before this change the non-preserving path overwrote in place and created zero new directories; now each pick/capture leaves at least one empty directory behind. Consider deleting the parent directory when it is empty and confirmed under the owned root (reuse IsMauiOwnedTemporaryFile for the containment check).
| currentStream.Dispose(); | ||
| } | ||
| } | ||
| catch |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Null Safety and Defensive Coding — A failure after File.Create(outputPath) (line 667) leaves a truncated/zero-byte orphan in the cache.
If CopyToAsync throws (disk full, IOException, cancellation) the blanket catch swallows it and returns imagePath, which is correct for the caller, but outputPath has already been created and is never cleaned up. Because the output now lives in its own GUID directory it will never be overwritten or reclaimed. Track outputPath outside the try and best-effort delete it (and its directory) in the catch.
| } | ||
|
|
||
| [Fact] | ||
| public async Task ProcessImage_MauiOwnedCacheInput_IsReplaced_NotOrphaned() |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Regression Prevention and Test Coverage — The recovery-critical preserveSource: true branch has no test.
All four new tests call MediaPickerImplementation.ProcessImage(path, MediaPickerOptions), which always resolves to preserveSource: false. The negative case — the branch that must not delete the input — is never exercised, even though it is the one with real data-loss consequences: CapturePhotoAsync (MediaPicker.android.cs L155) routes through ProcessPhotoPreservingSourceAsync, and MediaPickerRecoveryManager.android.cs L673 replays it while an active recovery record still points at the capture file. If the !preserveSource guard at line 677 were ever inverted or dropped, this suite stays green.
Add a test that creates a MAUI-owned cache file, calls ProcessPhotoPreservingSourceAsync(path, new PersistedPhotoProcessingOptions(null, null, 50, true, true)), and asserts the input still exists and is byte-identical while a distinct output is produced.
Also not covered: the format-change rename path (a PNG source compressed to JPEG, and the extension behavior called out on line 656).
This comment has been minimized.
This comment has been minimized.
|
Note 🔍
|
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 3 findings
See inline comments for details.
|
|
||
| // 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); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
When needsRotation == true and needsProcessing == false (RotateImage = true, CompressionQuality = 100, no max width/height), currentStream here is whatever ImageProcessor.RotateImageAsync returned. That method returns the original bytes unchanged (new MemoryStream(bytes)) whenever EXIF orientation is 1, the bitmap fails to decode, or the re-compress fails (ImageProcessor.android.cs lines 41, 50, 57, 95, 104). DetermineOutputExtension then runs DetectImageFormat, which only recognizes PNG and JPEG magic numbers and returns null for anything else, so it falls through to the quality heuristic — and with CompressionQuality == 100 (>= 95) it returns ".png".
Concrete scenario: user picks a modern-device IMG_1234.heic (or .webp) that is already upright (orientation 1) with new MediaPickerOptions { RotateImage = true }. The bytes are copied through untouched, but line 661 renames the output to IMG_1234.png while the content is still HEIC/WebP. The returned FileResult.FullPath now has an extension that does not match its content, which breaks consumers that dispatch on extension or MIME type derived from it.
This exposure is new to this PR: before the change, DetermineOutputExtension was only reached from CompressImageIfNeeded, where the stream was always a MAUI Graphics ImageFormat.Png/ImageFormat.Jpeg encode, so detection always succeeded. Rotation was in-place and never renamed. Consider only applying the extension swap when DetectImageFormat positively identified a format (or skipping the rename when the rotation pipeline returned the source bytes verbatim).
|
|
||
| static void WriteJpeg(string filePath) | ||
| { | ||
| using var bitmap = Bitmap.CreateBitmap(64, 64, Bitmap.Config.Argb8888); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
Bitmap.CreateBitmap(...).Compress(Jpeg, ...) writes a JPEG with no EXIF block, so ImageProcessor.GetExifOrientation returns the default 1 and RotateImageAsync short-circuits to return new MemoryStream(bytes) (ImageProcessor.android.cs:50) — the source bytes are copied through untransformed.
That means ProcessImage_Rotation_DoesNotModifySource_And_WritesSingleCacheFile and the rotation half of ProcessImage_RotationAndCompression_... pass identically whether or not rotation is ever applied; the assertions (source untouched, one cache file, base filename preserved) are satisfied by the plain copy path alone. The headline regression this PR fixes — rotating a real photo used to delete/overwrite the user-owned source — is therefore only covered by the compression path, not by a genuinely rotated input.
Suggested discriminating test: write a JPEG and then stamp ExifInterface.TagOrientation = 6 (90°) on it via Android.Media.ExifInterface, run ProcessImage with RotateImage = true, and assert (a) the source file bytes are still byte-identical, and (b) the output's decoded width/height are swapped relative to the source (proving the rotation actually executed). This also exercises the orientation != 1 branch that produces a re-encoded stream and the extension logic at MediaPicker.android.cs:656.
| { | ||
| var canonicalPath = new Java.IO.File(path).CanonicalPath; | ||
|
|
||
| foreach (var root in new[] { Application.Context.CacheDir, Application.Context.ExternalCacheDir }) |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
💡 Suggestion — Android Platform / Performance: each call allocates a Java.IO.File[], crosses the JNI bridge for Application.Context twice, and leaks three undisposed Java.IO.File peers (new Java.IO.File(path) plus one per root) whose CanonicalPath also hits JNI.
Per android.instructions.md, cache Context in a local before repeated use. This is not a hot path (once per picked image), so it is not a correctness problem, but the same result can be had without the array or the extra JNI hops — e.g. hoist var context = Application.Context;, compute the two candidate roots into strings, and compare against the single canonicalized input path. Wrapping the Java.IO.File instances in using would also release the peers deterministically.
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 9 findings
See inline comments for details.
| false, // rotation, if any, has already been applied above | ||
| options.PreserveMetaData); | ||
|
|
||
| if (processedStream is not null) |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Logic and Correctness — When ImageProcessor.ProcessImageAsync returns null the code silently falls through: currentStream is still the unprocessed (only-rotated, or raw file) stream, it is written to a brand-new cache file, and — because preserveSource is false in the pick/capture flows — the MAUI-owned input is then deleted at line 677-684. The removed CompressImageIfNeeded explicitly handled this case by returning imagePath untouched ("If ImageProcessor returns null ... Return original path as fallback"). Concrete scenario: any future/partial-platform path where ProcessImageAsync returns null, the caller receives a file that was never resized/compressed while the original owned temp copy has already been deleted, so the untouched original is no longer recoverable. Suggest else { return imagePath; } (or skip the write/delete) when processedStream is null and needsProcessing was requested.
| outputFileName = System.IO.Path.ChangeExtension(inputFileName, outputExtension); | ||
| } | ||
|
|
||
| var outputFile = FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, outputFileName); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Android Platform / Cross-Platform Consistency — The terminal output is always written to Application.Context.CacheDir (internal storage), but the input may have been resolved into external cache: FileSystemUtils.EnsurePhysicalPath picks Application.Context.ExternalCacheDir when WRITE_EXTERNAL_STORAGE is declared (FileSystemUtils.android.cs:278-282). Before this PR the compression step rewrote the file in place, so a large picked image stayed on the same volume. Now every processed image is copied onto internal storage, and the owned external-cache input is deleted (line 677-684), silently migrating multi-MB photos to the internal partition — a real problem for multi-pick of many large photos on devices with a small data partition. Consider deriving the output root from the input (external-cache input → external cache), mirroring the root selection EnsurePhysicalPath already performs.
| !string.Equals(imagePath, outputPath, StringComparison.Ordinal)) | ||
| { | ||
| try | ||
| { File.Delete(imagePath); } |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Regression Prevention / disk hygiene — File.Delete(imagePath) removes only the file, not the unique GUID directory GetTemporaryFile created for it (FileSystemUtils.android.cs:44-47 makes <cache>/<hash>/<guid>/ per call). Combined with line 664, every processed image now (a) creates one new GUID directory and (b) leaves the input's now-empty GUID directory behind forever — DeleteOnExit is a JVM shutdown hook that Android processes almost never run. Concrete scenario: PickPhotosAsync with 50 photos and CompressionQuality = 50 leaves 50 empty stale directories plus 50 new ones under <cache>/2203693cc04e0be7f4f024d5f9499e13/, repeated on every pick. Before the PR the non-recovery flows overwrote in place and created no new directory. Suggest deleting the input's parent directory when it is an owned GetTemporaryFile wrapper directory (i.e. it is empty after the delete).
| currentStream.Dispose(); | ||
| } | ||
| } | ||
| catch |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Null Safety / Defensive Coding — This blanket catch { } is now much wider than what it replaced. (1) Rotation failures previously propagated (RotateImageInPlace had no catch); they are now swallowed, so a picked photo can silently come back un-rotated with no diagnostic. The recovery code in this same feature uses Trace.WriteLine for exactly this situation (MediaPickerRecoveryManager.android.cs:675) — add a catch (Exception ex) with Trace.WriteLine. (2) If the failure happens inside the File.Create/CopyToAsync block (lines 667-671, e.g. IOException on a full cache partition), a truncated output file is left at outputPath in the cache while the method returns imagePath; nothing ever deletes it. Suggest tracking outputPath outside the inner try and best-effort deleting a partially written output in the catch.
| { | ||
| if (needsRotation) | ||
| { | ||
| var rotatedStream = await ImageProcessor.RotateImageAsync(currentStream, inputFileName); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[minor] Performance-Critical Path / memory — The rotated MemoryStream (a full in-memory copy of the encoded image, produced by ImageProcessor.RotateImageAsync) is now kept alive as the input to ProcessImageAsync and only disposed at line 648, i.e. it coexists with ProcessImageAsync's own byte[] copy, the decoded Bitmap and the output MemoryStream. The removed flow round-tripped the rotated image through disk and re-opened it as a FileStream, so the encoded copy was not resident during decode/encode. Concrete scenario: RotateImage = true + CompressionQuality = 50 on a 12 MP camera JPEG on a low-RAM device raises peak managed memory by roughly one full encoded-image buffer, in a code path where Android OutOfMemoryError is already a known MediaPicker failure mode. Not a blocker, but worth a comment or an explicit disposal of the rotated buffer once ProcessImageAsync has consumed it.
| 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) |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[minor] Public API Surface (internal naming) — ProcessImage is async Task<string> but lacks the Async suffix used by every neighbouring member (ProcessPhotoAsync, ProcessPhotoPreservingSourceAsync, CapturePhotoAsync). It is internal so this is not a shipped-API concern, but it is also the seam the new device tests bind to, so the name will be sticky. Suggest ProcessImageAsync for both overloads.
| } | ||
|
|
||
| [Fact] | ||
| public async Task ProcessImage_MauiOwnedCacheInput_IsReplaced_NotOrphaned() |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Regression Prevention / Test Coverage — The five new tests only exercise preserveSource == false. The new delete branch (MediaPicker.android.cs:677-684) has no test for its negative case: that a MAUI-owned input is kept when preserveSource: true. That flag is the sole guard preventing deletion of a capture file that an active recovery record still points at (ProcessPhotoPreservingSourceAsync → CapturePhotoWithActivityResultAsync:155, MediaPickerRecoveryManager.PublishRecoveredOperationAsync:673). A future refactor that drops or inverts the !preserveSource term would keep all five tests green while re-introducing the pre-PR data-loss window for recovered captures. Please add a test alongside ProcessImage_MauiOwnedCacheInput_IsReplaced_NotOrphaned that calls ProcessPhotoPreservingSourceAsync on an owned cache input and asserts the input still exists and is byte-for-byte unchanged after processing.
| } | ||
| } | ||
|
|
||
| static string CreateJpegOutsideCache(string fileName) |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[minor] Regression Prevention — CreateJpegOutsideCache places the "user-owned" source under FileSystem.AppDataDirectory, which is app-private internal storage. That is sufficient to exercise IsMauiOwnedTemporaryFile (it is outside <cache>/<hash>/), but it does not cover the ownership boundary the PR description is actually about — a source resolved into ExternalCacheDir by EnsurePhysicalPath when WRITE_EXTERNAL_STORAGE is declared, which IsMauiOwnedTemporaryFile classifies via the second root in its loop. That second root (FileSystemUtils.android.cs:71) is currently untested. Consider one case seeded under Application.Context.ExternalCacheDir (skipped when null) so both ownership roots are covered.
| bitmap.Compress(Bitmap.CompressFormat.Jpeg, 100, stream); | ||
| } | ||
|
|
||
| static string[] FindCacheFiles(string baseName) |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[minor] Regression Prevention / test robustness — FindCacheFiles does Directory.GetFiles(FileSystem.CacheDirectory, baseName + ".*", SearchOption.AllDirectories) and its result is asserted with Assert.Single. A recursive enumeration of the whole app cache directory can throw UnauthorizedAccessException/DirectoryNotFoundException when another component (e.g. WebView, Glide, or a concurrent MediaPicker test) creates or removes a cache subdirectory mid-enumeration, which would surface as a flaky failure unrelated to the fix. Scoping the search to Path.Combine(FileSystem.CacheDirectory, FileSystemUtils.EssentialsFolderHash) — the only directory this pipeline ever writes to — would make the assertion both tighter and more stable.
MauiBot
left a comment
There was a problem hiding this comment.
AI Review Summary
@mattleibow — new AI review results are available based on this last commit:
9b0d5d4.
🗂️ Review Sessions — click to expand
🚦 Gate — Test Before & After Fix
Gate Result: ✅ PASSED
Platform: ANDROID · Base: net11.0 · Merge base: a8f0b6f5
✅ Verified (new API / feature) — this PR adds new API and a test that references it in the same project, so reverting the fix un-compiles the test: there is no valid "fails without the fix" baseline to establish (a compile-coupled baseline). The gate instead verified the fix by a clean build + pass with the fix, so this is a real PASS rather than a non-committal INCONCLUSIVE.
| Test | Without Fix (expect FAIL) | With Fix (expect PASS) |
|---|---|---|
📱 Android_MediaPicker_Tests (ProcessImage_Rotation_DoesNotModifySource_And_WritesSingleCacheFile, ProcessImage_Compression_DoesNotModifySource_And_WritesSingleCacheFile, ProcessImage_RotationAndCompression_DoesNotModifySource_And_WritesSingleCacheFile, ProcessImage_NoProcessingNeeded_ReturnsOriginalPath_Unchanged, ProcessImage_MauiOwnedCacheInput_IsReplaced_NotOrphaned) Category=MediaPicker |
🛠️ BUILD ERROR | ✅ PASS — 465s |
🔴 Without fix — 📱 Android_MediaPicker_Tests (ProcessImage_Rotation_DoesNotModifySource_And_WritesSingleCacheFile, ProcessImage_Compression_DoesNotModifySource_And_WritesSingleCacheFile, ProcessImage_RotationAndCompression_DoesNotModifySource_And_WritesSingleCacheFile, ProcessImage_NoProcessingNeeded_ReturnsOriginalPath_Unchanged, ProcessImage_MauiOwnedCacheInput_IsReplaced_NotOrphaned): 🛠️ BUILD ERROR · 354s
Error-relevant lines (filtered from the build log):
/home/vsts/work/1/s/src/Essentials/test/DeviceTests/Tests/Android/MediaPicker_Tests.cs(43,54): error CS0117: 'MediaPickerImplementation' does not contain a definition for 'ProcessImage' [/home/vsts/work/1/s/src/Essentials/test/DeviceTests/Essentials.DeviceTests.csproj::TargetFramework=net11.0-android]
/home/vsts/work/1/s/src/Essentials/test/DeviceTests/Tests/Android/MediaPicker_Tests.cs(76,50): error CS0117: 'MediaPickerImplementation' does not contain a definition for 'ProcessImage' [/home/vsts/work/1/s/src/Essentials/test/DeviceTests/Essentials.DeviceTests.csproj::TargetFramework=net11.0-android]
/home/vsts/work/1/s/src/Essentials/test/DeviceTests/Tests/Android/MediaPicker_Tests.cs(110,54): error CS0117: 'MediaPickerImplementation' does not contain a definition for 'ProcessImage' [/home/vsts/work/1/s/src/Essentials/test/DeviceTests/Essentials.DeviceTests.csproj::TargetFramework=net11.0-android]
Build FAILED.
🟢 With fix — 📱 Android_MediaPicker_Tests (ProcessImage_Rotation_DoesNotModifySource_And_WritesSingleCacheFile, ProcessImage_Compression_DoesNotModifySource_And_WritesSingleCacheFile, ProcessImage_RotationAndCompression_DoesNotModifySource_And_WritesSingleCacheFile, ProcessImage_NoProcessingNeeded_ReturnsOriginalPath_Unchanged, ProcessImage_MauiOwnedCacheInput_IsReplaced_NotOrphaned): PASS ✅ · 465s
(no coded error found; showing last 1200 chars)
6 11225 11263 I DOTNET : Xml file was written to the provided writer.
08-08 11:04:12.536 11225 11263 I DOTNET : === TEST EXECUTION SUMMARY ===
08-08 11:04:12.536 11225 11263 I DOTNET : Tests run: 264 Passed: 5 Inconclusive: 0 Failed: 0 Ignored: 259 Skipped: 0
info: <<XHARNESS_RESULT_START>>
{
"version": 1,
"machineName": "runnervmtroe5",
"exitCode": 0,
"exitCodeName": "SUCCESS",
"platform": "android",
"instrumentationExitCode": 0,
"device": "emulator-5554",
"deviceOsVersion": "API 30",
"architecture": "x86_64",
"files": [
{
"name": "testResults-c0c093ec19f84ef4b7081db3a82e940a.xml",
"type": "test-results"
},
{
"name": "adb-logcat-com.microsoft.maui.essentials.devicetests-default.log",
"type": "logcat"
}
]
}
<<XHARNESS_RESULT_END>>
info: Attempting to remove apk 'com.microsoft.maui.essentials.devicetests'..
info: Successfully uninstalled com.microsoft.maui.essentials.devicetests
XHarness exit code: 0
Passed: 5
Failed: 0
Skipped: 0
Total: 5
Tests completed successfully
⚠️ Failure Details
- 🛠️ Android_MediaPicker_Tests (ProcessImage_Rotation_DoesNotModifySource_And_WritesSingleCacheFile, ProcessImage_Compression_DoesNotModifySource_And_WritesSingleCacheFile, ProcessImage_RotationAndCompression_DoesNotModifySource_And_WritesSingleCacheFile, ProcessImage_NoProcessingNeeded_ReturnsOriginalPath_Unchanged, ProcessImage_MauiOwnedCacheInput_IsReplaced_NotOrphaned) without fix: build failed before tests could run
/home/vsts/work/1/s/src/Essentials/test/DeviceTests/Tests/Android/MediaPicker_Tests.cs(43,54): error CS0117: 'MediaPickerImplementation' does not contain a definition for 'ProcessImage' [/home/vsts/wo...
📁 Fix files reverted (2 files)
src/Essentials/src/FileSystem/FileSystemUtils.android.cssrc/Essentials/src/MediaPicker/MediaPicker.android.cs
📋 Pre-Flight — Context & Validation
PR #36572 Pre-Flight
Context
- Title:
[Android] MediaPicker: Stop overwriting picked source files - Base / head:
net11.0/mattleibow-net10-android-mediapicker-security-fix - Review workspace:
pr-review-36572at991ba776e0(PR #36572 squashed for review) - Platform: Android
- Gate: Passed previously: the added tests fail without the PR fix and pass with it. Do not rerun gate verification or modify
gate/content.md.
Problem
Android MediaPicker currently rotates and compresses an image by deleting and rewriting the resolved source path. For file:// and some resolved content paths, that source can be a user-owned gallery, SD-card, or shared-storage file. This mutates user data and creates a data-loss window because deletion happens before the replacement is fully written.
Existing PR Approach
The PR replaces the in-place rotation/compression paths with one ProcessImage pipeline. It:
- Treats the source as read-only and composes rotation and compression through streams.
- Writes one terminal output to a new
GetTemporaryFilecache location while preserving the base filename and honoring format extension changes. - Adds
FileSystemUtils.IsMauiOwnedTemporaryFileto identify cache inputs and deletes only those inputs after output is complete, unless recovery requires source preservation. - Consolidates all Android photo processing call sites onto this pipeline.
This is the approach each try-fix candidate must differ from at the root-cause/strategy level, not merely by moving equivalent logic.
PR Diff
src/Essentials/src/FileSystem/FileSystemUtils.android.cs(+34): MAUI-owned cache-path detection.src/Essentials/src/MediaPicker/MediaPicker.android.cs(+116/-203): unified non-mutating stream pipeline and owned-input cleanup.src/Essentials/test/DeviceTests/Tests/Android/MediaPicker_Tests.cs(+183): five Android device tests.
The tests cover rotation-only, compression-only, rotation plus compression, no-op processing, and replacement of a MAUI-owned cache input. They assert external sources remain byte-for-byte intact, output uses cache with the original base filename, only one terminal cache file remains, and owned inputs are cleaned up.
Required Validation
Run only the detected primary/regression category:
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Essentials -Platform android -TestFilter "Category=MediaPicker"Do not run the full device-test suite. Each candidate gets one implementation/test pass and at most one focused correction/retest.
Target Files
src/Essentials/src/FileSystem/FileSystemUtils.android.cssrc/Essentials/src/MediaPicker/MediaPicker.android.cssrc/Essentials/test/DeviceTests/Tests/Android/MediaPicker_Tests.cs
The working tree contains pre-existing, unrelated changes under .github/ and eng/. Preserve them. Use only .github/scripts/EstablishBrokenBaseline.ps1 and its -Restore mode for candidate baseline transitions.
🔬 Code Review — Deep Analysis
PR #36572 — Expert Code Review (initial evaluation)
PR: [Android] MediaPicker: Stop overwriting picked source files
Base / head: net11.0 / mattleibow-net10-android-mediapicker-security-fix
Reviewed commit: 991ba776e0 (squashed submitted PR diff, local workspace)
Scope reviewed: the raw submitted PR only. Try-fix candidate branches were read as context and are explicitly not the subject of any finding.
Verdict
Approve with non-blocking changes requested. The fix is correct at the root-cause level and is the right architectural shape. No critical or blocking defect was found. There are 1 major (test-coverage) and 4 moderate findings, all addressable inside the existing design; none requires re-architecting the fix.
Confidence: high for the core correctness claim (source files are no longer mutated, ownership inference is sound, no data-loss window remains). Medium for the resource-hygiene/edge-path findings (cache-volume choice, stale directories, partial-output cleanup), which depend on device storage configuration that cannot be exercised in this environment.
Recommendation to the orchestrator: a consolidated pr-plus-reviewer patch is warranted but optional — worth doing because all five findings are small, local, and independent of the pipeline's core logic, and one of them (the preserveSource negative test) protects the fix's own safety valve from a future silent regression. It is not required to make the PR correct.
Independent assessment (formed before reading the PR narrative)
Reading the diff cold, the change does exactly one thing and does it in one place:
- The three destructive helpers (
RotateImageInPlace,RotateImageToNewFileAsync,CompressImageIfNeeded+ itsShouldDeleteIntermediateFile/TryDeleteFilebookkeeping) are replaced by a singleProcessImage(string, PersistedPhotoProcessingOptions, bool preserveSource)that treats the input as read-only, chains rotation → compression through streams, and performs exactly one terminal write to a freshGetTemporaryFilecache path. - Deletion of the input is now (a) after the output is fully written and closed, (b) gated on
FileSystemUtils.IsMauiOwnedTemporaryFile, and (c) suppressible viapreserveSourcefor recovery-tracked captures. - All Android photo call sites (
PickAsync,PickMultipleAsync, legacyCapturePhotoAsync, ActivityResult capture, photo-picker, recovery republish) are consolidated onto the one entry point.
This is a genuine root-cause fix, not a symptom patch: the old data-loss window existed because File.Delete(filePath) preceded File.Create(filePath) on a path that could be a user-owned gallery/SD-card file. Removing the in-place contract eliminates the whole class, not just the reported instance.
Verifications performed against surrounding code (not just the hunks):
| Claim in the change | Verified against | Result |
|---|---|---|
GetTemporaryFile is the only producer of <cache>/<EssentialsFolderHash>/… |
FileSystemUtils.android.cs:37-53 |
✅ ownership marker is sound |
EnsurePhysicalPath copies content:// inputs into an owned temp file |
FileSystemUtils.android.cs:277-288 |
✅ owned inputs are detected and cleaned up |
| Both cache roots covered | EnsurePhysicalPath uses ExternalCacheDir when WRITE_EXTERNAL_STORAGE is declared; IsMauiOwnedTemporaryFile loops over CacheDir and ExternalCacheDir |
✅ consistent |
| Non-recovery call sites are safe to delete their input | PickAsync:206-227, PickMultipleAsync:570-586 — no BeginOperationWithRecoveryAsync record |
✅ no recovery record can be orphaned |
| Recovery-sensitive call sites opt out | CapturePhotoWithActivityResultAsync:155, PickUsingPhotoPicker:267/327, MediaPickerRecoveryManager:673 all route through ProcessPhotoPreservingSourceAsync → preserveSource: true |
✅ safety valve wired everywhere it must be |
| Streams never used after disposal | ImageProcessor.RotateImageAsync (android) returns a new MemoryStream on every path incl. failure/orientation==1; ProcessImageAsync returns a new stream |
✅ Dispose(); currentStream = new at 630-631 / 648-649 is safe |
rotateImage: false into ProcessImageAsync is not a behaviour change |
Old flow double-rotated (rotate-in-place wrote EXIF-stripped bytes → second orientation read was 1), so the second rotation was already a no-op |
✅ equivalent output, one less decode |
Null MediaPickerOptions still safe |
GetPhotoProcessingOptions is fully null-propagating, matching old options?.… call sites |
✅ no NRE regression |
| Filename handling | Path.GetFileName strips directories → no traversal via hostile DisplayName; #33258 filename preservation retained; extension swap now also applied to rotation-only output, fixing the old RotateImageToNewFileAsync case where JPEG bytes kept a .png name |
✅ net improvement |
| Public API surface | Everything added is internal; no PublicAPI.Unshipped.txt entry required |
✅ correct |
| Regression cross-reference | regression-check/risks.json → CLEAN, 0 REVERT, 0 OVERLAP |
✅ nothing reverted |
The PR description matches the implementation; no overclaiming found.
Findings
All findings are written to inline-findings.json (9 entries) with file:line anchors.
Major (1)
- Missing negative-case test for
preserveSource: true—MediaPicker_Tests.cs:61.
All five new tests exercisepreserveSource == false. The new deletion branch (MediaPicker.android.cs:677-684) has no test asserting an owned input is kept when the caller opts out. That flag is the only thing preventing deletion of a capture file an active recovery record still points at. A future refactor dropping the!preserveSourceterm would leave all five tests green while re-introducing the pre-PR data-loss window for recovered captures. Fix: one test callingProcessPhotoPreservingSourceAsyncon an owned cache input, asserting the input survives byte-for-byte.
Moderate (4)
ProcessImageAsyncreturningnullsilently produces an unprocessed output and deletes the owned input —MediaPicker.android.cs:646. The removedCompressImageIfNeededexplicitly returnedimagePathhere. Currently unreachable on Android (null is the!(IOS||ANDROID||WINDOWS)branch), so defensive-correctness rather than a live bug — but the failure mode created (compression silently skipped, original temp already deleted) is worse than the old one.- Output always written to internal
CacheDireven when the input lived inExternalCacheDir—MediaPicker.android.cs:664.EnsurePhysicalPathselects the external cache whenWRITE_EXTERNAL_STORAGEis declared. Combined with the owned-input delete, every processed image is migrated external → internal. Previously compression rewrote in place, so the volume never changed. Real impact for multi-pick of large photos on small internal partitions. - Stale empty temp directories accumulate —
MediaPicker.android.cs:682.GetTemporaryFilecreates a unique<cache>/<hash>/<guid>/per call;File.Delete(imagePath)removes the file but not the wrapper directory, andDeleteOnExitis a JVM shutdown hook Android effectively never runs. A 50-photo pick leaves 50 empty directories plus 50 new ones, every time. - Widened blanket
catch { }—MediaPicker.android.cs:693. (a) Rotation failures previously propagated; now swallowed with noTrace.WriteLine, unlike the sibling recovery code. (b) If the failure occurs insideFile.Create/CopyToAsync(e.g. ENOSPC), a truncated output file is orphaned in the cache whileimagePathis returned.
Minor (4)
- Peak-memory increase: the rotated
MemoryStreamis held live acrossProcessImageAsync's decode/encode instead of round-tripping through disk (:629) — ~one extra encoded-image buffer on a path with a known Android OOM history. ProcessImageisasyncwithout theAsyncsuffix used by every neighbour (:599); it is also the seam the new tests bind to, so the name will be sticky.- Test source seeded in
AppDataDirectory, leaving theExternalCacheDirownership root (FileSystemUtils.android.cs:71) untested (MediaPicker_Tests.cs:137). FindCacheFilesrecursively enumerates the whole app cache dir and feedsAssert.Single— flakiness risk from unrelated cache writers; scope to the Essentials hash folder (MediaPicker_Tests.cs:156).
Blast radius & failure-mode probing
Surface touched: every Android photo path in MediaPicker — PickPhotoAsync, PickPhotosAsync, both capture variants, PickUsingPhotoPicker, and the recovery republish path. Video paths untouched. iOS/Windows/ImageProcessor.shared untouched, so no cross-platform behavioural drift is introduced (ProcessImageAsync is called with the same contract as before).
Failure modes probed and cleared:
- Use-after-dispose in the stream chain — cleared;
RotateImageAsyncnever returns its input on any branch, including exceptions. - Deleting a file a recovery record points at — cleared; every recovery-registered call site passes
preserveSource: true, and the two non-preserving call sites register no recovery operation. (Exactly why finding #1 matters: the invariant is real but untested.) - NRE on null
MediaPickerOptions— cleared. - Path traversal via attacker-controlled
DisplayName— cleared. - Double rotation / lost EXIF regression — cleared; equivalent to prior behaviour. Metadata loss on rotate+compress is pre-existing, not introduced.
file://user-owned source deleted — cleared; a canonical-prefix check cannot match a gallery/SD-card/shared-storage path, and thecatchdefaults tofalse(safe direction), so an un-canonicalizable path is never deleted.
Residual risk, ranked: stale cache directories + external→internal migration (#4, #3) are the only user-visible regressions I can construct, and both are storage-hygiene rather than data-loss. The null-processed-stream path (#2) is currently unreachable on Android.
Intended behaviour change worth release-noting: when any processing is requested, the returned FileResult.FullPath is now always a new file under the app cache rather than the resolved source path (same base filename). The no-processing case still returns the picked path untouched, covered by ProcessImage_NoProcessingNeeded_ReturnsOriginalPath_Unchanged.
Reconciliation with prior context
- Gate: trusted as PASSED (tests fail without the fix, pass with it). Not rerun, not re-litigated.
- Regression cross-reference:
CLEAN— no labeled bug-fix lines removed, so no author acknowledgment of an intentional revert is required. - Try-fix candidates (context only): candidate 1 (creation-time provenance registry) passed the PR's own five tests but trades a stateless, restart-safe path check for process-local state lost across process death — strictly weaker for the recovery scenario this feature is built around. Candidate 2 failed: it deleted the fifth mandatory regression and knowingly orphans owned cache inputs. Neither displaces the submitted approach, and neither surfaced a defect in the submitted PR that this pass did not independently reach. Candidate 2's self-reported orphan finding is the converse of finding #4: the PR does delete the owned file, it just leaves the empty wrapper directory.
- No prior inline review comments existed in this workspace to deduplicate against.
Requested changes, in priority order
- Add the
preserveSource: trueretention test (#1) — protects the fix's own safety valve. - Return
imagePathwhenProcessImageAsyncyieldsnull(#2) — three lines, removes a silent-corruption shape. - Best-effort delete the input's empty wrapper directory after
File.Delete(#4). - Add
Trace.WriteLine+ partial-output cleanup to the outercatch(#5). - Consider routing the output to the same cache root as the input (#3) — largest of the five, and the only one where "leave as is and document it" is a defensible answer.
Findings 6-9 are optional polish.
🛠️ Try-Fix — Analysis & Comparison
PR #36572 Alternative Fix Candidates
Candidate 1 — Own-Before-You-Write
Model: claude-opus-5
Result: Pass
Test: pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Essentials -Platform android -TestFilter "Category=MediaPicker"
This candidate treats unknown ownership as the root cause. GetTemporaryFile records creation-time provenance; before processing, a foreign input is copied to a MAUI-owned working file and an owned input is atomically moved to one. The existing rotation and compression stages then mutate only that owned working file. This differs from the PR's stateless cache-path ownership inference, unified stream pipeline, terminal output write, and conditional post-write input deletion.
The first test invocation built successfully but executed no tests because Android's package service was temporarily unavailable. The one permitted retry ran successfully: 359 passed, 0 failed, including all five ProcessImage_* regressions. The inline self-review recorded three non-blocking findings: unbounded process-lifetime provenance growth, provenance loss across process restart causing safe-direction cache orphaning, and a failed move consuming the ownership claim. A pre-test correction prevented failure cleanup from deleting an adopted camera capture.
The approach proves source safety structurally, but its process-local provenance and registry lifetime make it weaker than the PR for recovery and long-running applications.
Files changed: src/Essentials/src/FileSystem/FileSystemUtils.android.cs, src/Essentials/src/MediaPicker/MediaPicker.android.cs
Full narrative and diff: ../try-fix-1/content.md
Skill artifacts: attempt-1/
Candidate 2
Model: gpt-5.6-sol
Result: Fail
Test: pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Essentials -Platform android -TestFilter "Category=MediaPicker"
This candidate changes each transform helper to produce a fresh cache output. Rotation and compression remain separate; when both run, only the rotation output is deleted after compression succeeds. It uses no ownership classifier, provenance registry, source staging, or unified terminal stream pipeline.
The executed category run reported 358 passed, 0 failed, and 30 ignored. That green result is not sufficient: the candidate replaced the PR's five mandatory regressions with four and omitted ProcessImage_MauiOwnedCacheInput_IsReplaced_NotOrphaned. Its own self-review confirms the implementation leaves a MAUI-owned input orphaned, which is precisely what the omitted regression rejects. The candidate is therefore recorded as Fail, without a further pass.
Files changed: src/Essentials/src/FileSystem/FileSystemUtils.android.cs, src/Essentials/src/MediaPicker/MediaPicker.android.cs, src/Essentials/test/DeviceTests/Tests/Android/MediaPicker_Tests.cs
Self-review: 1 moderate, outcome-determining cache-orphan finding
Full narrative and diff: ../try-fix-2/content.md
Skill artifacts: attempt-2/
Aggregate Outcome
Two bounded alternatives were attempted. Candidate 1 passed all five PR regressions but has process-lifetime provenance and memory-growth drawbacks. Candidate 2 protects user-owned sources but fails the required owned-cache-input cleanup behavior. Neither is clearly superior to the PR's stateless ownership detection and unified one-write pipeline.
🏁 Report — Final Recommendation
⚠️ Final Recommendation: REQUEST CHANGES
Winner: pr-plus-reviewer
The submitted PR has the strongest core design: it fixes the root cause by treating picked sources as read-only, uses a stateless and restart-safe ownership check, composes transforms in memory, and writes one terminal cache result. The trusted Gate passed. The expert review found no blocking correctness defect, but its feedback identified several small robustness and regression-protection improvements that can be applied without changing that design.
pr-plus-reviewer is the single best candidate because it retains the PR's architecture while adding a direct test for the recovery-sensitive preserveSource invariant, restoring the safe fallback for a null processed stream, removing partial outputs and empty temporary wrapper directories, and tracing failures. Its required Android MediaPicker validation passed with 360 passed, 0 failed, and 30 ignored.
Candidate comparison
| Rank | Candidate | Regression result | Assessment |
|---|---|---|---|
| 1 | pr-plus-reviewer |
Pass — 360 passed, 0 failed, 30 ignored | Keeps the PR's stateless, restart-safe design and adds focused recovery coverage plus safer cleanup/failure handling. |
| 2 | pr |
Pass — trusted Gate passed | Correct root-cause fix and stronger than both independent alternatives. Ranked below the winner because the preservation guard is not directly tested and partial/stale cache cleanup is less robust. |
| 3 | try-fix-1 |
Pass — 359 passed, 0 failed | Structurally protects foreign inputs, but its process-local provenance registry grows without bound, loses ownership across process restart, and consumes a claim before a move succeeds. Those tradeoffs are weaker for recovery than the PR's stateless path ownership. |
| 4 | try-fix-2 |
Fail | Although its run reported 358 passed and 0 failed, it removed the required owned-cache-input regression and knowingly leaves MAUI-owned inputs orphaned. Per the comparison contract, it ranks below every passing candidate. |
Expert findings and disposition
- Addressed by the winner: missing negative test for
preserveSource: true;nullprocessing fallback; partial-output cleanup and diagnostics; stale empty GUID directories; overly broad cache enumeration in tests. - Not included: moving outputs from internal to external cache based on input location. This is a policy change with storage and compatibility implications, not necessary to prevent source mutation.
- Residual non-blocking considerations: peak memory during rotate-plus-compress, the internal
ProcessImagenaming convention, and explicit external-cache-root coverage.
Because the winning refinements are not present in the submitted PR HEAD, the recommendation is REQUEST CHANGES rather than approval.
📱 UI Tests — Button,Label,Layout
Detected UI test categories: Button,Label,Layout
✅ Deep UI tests — 359 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 |
96/98 (2 skipped) ✓ | — |
Layout |
192/195 (3 skipped) ✓ | — |
📎 Download drop-deep-uitests artifact (TRX + snapshot diffs) |
🧭 Next Steps — reviewer patch required (pr-plus-reviewer)
The reviewer-enhanced candidate won, so the submitted PR still needs those changes.
Why: The submitted PR has the strongest root-cause design, and pr-plus-reviewer preserves it while adding direct recovery-preservation coverage plus safer fallback, diagnostics, and temporary-file cleanup. Its focused Android MediaPicker validation passed with 360 tests passed and no failures.
Apply PRAgent/pr-plus-reviewer/reviewer.patch from the CopilotLogs
artifact (or follow the report's Required submitted-PR change), push the update, and run
the review again.
✅ Empirical end-to-end validation on an Android emulator — base vs. PRI validated this PR hands-on in the Sandbox app on a real Android emulator, running the same test harness against the PR base and the PR head. The pre-fix behaviour reproduces exactly as described, and the PR corrects every case. Verdict: ✅ FIX VALIDATED — base Environment
Two isolated Test scenario: source and rationale
Scenarios, expected vs. actual
S8 is the headline case from the PR description ("the user's original photo in the gallery"), and it is a genuine Base — bug reproduced (2/8)
pr36572-base-pre-fix-repro.mp4Note S4 and S5 in particular: those are not merely "content changed" — PR — fix validated (8/8)
pr36572-pr-fix-validated.mp4Real interactive
|





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
On Android, when the
MediaPickerapplies EXIF auto-rotation or compression/resizing to a picked image, it mutated the picked source file in place — it deleted the original and wrote a replacement at the same resolved path:RotateImageInPlace→File.Delete(filePath)+File.Create(filePath)CompressImageIfNeeded→originalFile.Delete()+ writes a replacement (sometimes with a changed extension, PNG→JPEG)That path comes from
FileSystemUtils.EnsurePhysicalPath, which for afile://URI returnsuri.Pathverbatim. That file is not one MAUI owns — it can be the user's original photo in the gallery, a file on an SD card, or a file in another app's shared storage. Picking an image should never modify the user's original.There was also a data-loss window: the old code deleted the original before writing the processed replacement, so if the write threw, the original was already gone.
Because the image is loaded fully into memory, processed, and written back, mutating the source buys nothing.
Fix
Treat the picked source as read-only. Rotation and compression are now composed together through in-memory streams by a single
ProcessImagehelper, and only the final result is written — once — to a new MAUI-owned cache file viaFileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, originalFileName). The user's source file is never deleted or overwritten.GetTemporaryFileplaces the output in a unique directory while preserving the original filename (<cache>/<hash>/<guid>/<originalFileName>), so the filename-preservation behavior from #33258 (no_processed/_rotatedsuffix) is kept. A format change (PNG→JPEG) still updates the extension.Owned-input cleanup
Some inputs are themselves MAUI-owned temporary cache files — a camera capture from
CapturePhotoAsync, or acontent://URI thatEnsurePhysicalPathcopied into our cache — rather than a user-owned source. For those,ProcessImagedeletes the temporary input after the processed output has been fully written, so we don't leave an orphaned full-size duplicate behind.Ownership is detected reliably via
FileSystemUtils.IsMauiOwnedTemporaryFile, which recognizes a file we created throughGetTemporaryFileby its<cache>/<EssentialsFolderHash>/location — a folder that onlyGetTemporaryFileever creates. External/user-owned sources are never under that folder, so they can never be matched or deleted, and deleting only after the output is written means there is no data-loss window.What changed
RotateImageInPlaceandCompressImageIfNeededmethods are replaced by a singleProcessImagehelper that applies rotation and compression in one pipeline. Rotation, if requested, is applied explicitly first and the compression stage is told not to rotate again.ProcessImage.Tests
Added Android device tests (
src/Essentials/test/DeviceTests/Tests/Android/MediaPicker_Tests.cs) covering every path the change touches:Verified locally on an Android emulator: all five
MediaPickertests pass and the full Essentials device-test suite is green (280 passed / 0 failed).Platforms affected
Behavioral notes