-
Notifications
You must be signed in to change notification settings - Fork 2k
[Android] MediaPicker: Stop overwriting picked source files #36572
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,73 +25,14 @@ partial class MediaPickerImplementation : IMediaPicker | |
| public bool IsCaptureSupported | ||
| => Application.Context?.PackageManager?.HasSystemFeature(PackageManager.FeatureCameraAny) ?? false; | ||
|
|
||
| static async Task RotateImageInPlace(string filePath, MediaPickerOptions options) | ||
| { | ||
| await using var inputStream = File.OpenRead(filePath); | ||
| var fileName = System.IO.Path.GetFileName(filePath); | ||
| await using var rotatedStream = await ImageProcessor.RotateImageAsync(inputStream, fileName); | ||
| rotatedStream.Position = 0; | ||
| inputStream.Dispose(); // explicit close before delete | ||
| try | ||
| { File.Delete(filePath); } | ||
| catch { } | ||
| await using var outputStream = File.Create(filePath); | ||
| await rotatedStream.CopyToAsync(outputStream); | ||
| } | ||
|
|
||
| internal static async Task<string> ProcessPhotoAsync(string imagePath, MediaPickerOptions options) | ||
| { | ||
| // Apply rotation if needed for photos | ||
| if (imagePath is not null && ImageProcessor.IsRotationNeeded(options)) | ||
| { | ||
| await RotateImageInPlace(imagePath, options); | ||
| } | ||
|
|
||
| // Apply compression/resizing if needed for photos | ||
| if (imagePath is not null && ImageProcessor.IsProcessingNeeded(options?.MaximumWidth, options?.MaximumHeight, options?.CompressionQuality ?? 100)) | ||
| { | ||
| imagePath = await CompressImageIfNeeded(imagePath, options); | ||
| } | ||
|
|
||
| return imagePath; | ||
| } | ||
|
|
||
| internal static async Task<string> ProcessPhotoPreservingSourceAsync(string imagePath, PersistedPhotoProcessingOptions options) | ||
| { | ||
| if (imagePath is null) | ||
| { | ||
| return null; | ||
| } | ||
| internal static Task<string> ProcessPhotoAsync(string imagePath, MediaPickerOptions options) | ||
| => ProcessImage(imagePath, options); | ||
|
|
||
| var originalImagePath = imagePath; | ||
| string rotatedImagePath = null; | ||
|
|
||
| // Recovery-sensitive MediaPicker paths must leave the original file intact until the | ||
| // active recovery record has been cleared or promoted. | ||
| if (options.RotateImage) | ||
| { | ||
| var rotatedPath = await RotateImageToNewFileAsync(imagePath); | ||
| if (!string.Equals(rotatedPath, imagePath, StringComparison.Ordinal)) | ||
| { | ||
| rotatedImagePath = rotatedPath; | ||
| } | ||
|
|
||
| imagePath = rotatedPath; | ||
| } | ||
|
|
||
| if (ImageProcessor.IsProcessingNeeded(options.MaximumWidth, options.MaximumHeight, options.CompressionQuality)) | ||
| { | ||
| var compressedImagePath = await CompressImageIfNeeded(imagePath, options, preserveSource: true); | ||
| if (ShouldDeleteIntermediateFile(rotatedImagePath, originalImagePath, compressedImagePath)) | ||
| { | ||
| TryDeleteFile(rotatedImagePath); | ||
| } | ||
|
|
||
| imagePath = compressedImagePath; | ||
| } | ||
|
|
||
| return imagePath; | ||
| } | ||
| // 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. | ||
| internal static Task<string> ProcessPhotoPreservingSourceAsync(string imagePath, PersistedPhotoProcessingOptions options) | ||
| => ProcessImage(imagePath, options, preserveSource: true); | ||
|
|
||
| internal static PersistedPhotoProcessingOptions GetPhotoProcessingOptions(MediaPickerOptions options) | ||
| => new( | ||
|
|
@@ -279,15 +220,8 @@ void OnResult(Intent intent) | |
| { | ||
| if (photo) | ||
| { | ||
| // Apply rotation if needed | ||
| if (ImageProcessor.IsRotationNeeded(options)) | ||
| await RotateImageInPlace(path, options); | ||
|
|
||
| // Apply compression/resizing if needed | ||
| if (ImageProcessor.IsProcessingNeeded(options?.MaximumWidth, options?.MaximumHeight, options?.CompressionQuality ?? 100)) | ||
| { | ||
| path = await CompressImageIfNeeded(path, options); | ||
| } | ||
| // Apply rotation and/or compression if needed | ||
| path = await ProcessImage(path, options); | ||
| } | ||
|
|
||
| return new FileResult(path); | ||
|
|
@@ -428,123 +362,6 @@ void OnCreate(Intent intent) | |
| return captureFile.AbsolutePath; | ||
| } | ||
|
|
||
| static async Task<string> RotateImageToNewFileAsync(string imagePath) | ||
| { | ||
| if (string.IsNullOrEmpty(imagePath)) | ||
| { | ||
| return imagePath; | ||
| } | ||
|
|
||
| if (!File.Exists(imagePath)) | ||
| { | ||
| return imagePath; | ||
| } | ||
|
|
||
| await using var inputStream = File.OpenRead(imagePath); | ||
| var inputFileName = System.IO.Path.GetFileName(imagePath); | ||
| await using var rotatedStream = await ImageProcessor.RotateImageAsync(inputStream, inputFileName); | ||
| rotatedStream.Position = 0; | ||
|
|
||
| var outputExtension = System.IO.Path.GetExtension(imagePath); | ||
| if (string.IsNullOrEmpty(outputExtension)) | ||
| { | ||
| outputExtension = FileExtensions.Jpg; | ||
| } | ||
|
|
||
| var outputFile = FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, Guid.NewGuid().ToString("N") + outputExtension); | ||
| await using var outputStream = File.Create(outputFile.AbsolutePath); | ||
| await rotatedStream.CopyToAsync(outputStream); | ||
|
|
||
| return outputFile.AbsolutePath; | ||
| } | ||
|
|
||
| static bool ShouldDeleteIntermediateFile(string intermediatePath, string originalPath, string finalPath) | ||
| => !string.IsNullOrEmpty(intermediatePath) && | ||
| !string.Equals(intermediatePath, originalPath, StringComparison.Ordinal) && | ||
| !string.Equals(intermediatePath, finalPath, StringComparison.Ordinal); | ||
|
|
||
| static void TryDeleteFile(string filePath) | ||
| { | ||
| try | ||
| { File.Delete(filePath); } | ||
| catch { } | ||
| } | ||
|
|
||
| static Task<string> CompressImageIfNeeded(string imagePath, MediaPickerOptions options, bool preserveSource = false) | ||
| => CompressImageIfNeeded(imagePath, GetPhotoProcessingOptions(options), preserveSource); | ||
|
|
||
| static async Task<string> CompressImageIfNeeded(string imagePath, PersistedPhotoProcessingOptions options, bool preserveSource = false) | ||
| { | ||
| if (!ImageProcessor.IsProcessingNeeded(options.MaximumWidth, options.MaximumHeight, options.CompressionQuality) || string.IsNullOrEmpty(imagePath)) | ||
| return imagePath; | ||
|
|
||
| try | ||
| { | ||
| var originalFile = new Java.IO.File(imagePath); | ||
| if (!originalFile.Exists()) | ||
| { | ||
| return imagePath; | ||
| } | ||
|
|
||
| // Use ImageProcessor for unified image processing | ||
| using var inputStream = File.OpenRead(imagePath); | ||
| var inputFileName = System.IO.Path.GetFileName(imagePath); | ||
| using var processedStream = await ImageProcessor.ProcessImageAsync( | ||
| inputStream, | ||
| options.MaximumWidth, | ||
| options.MaximumHeight, | ||
| options.CompressionQuality, | ||
| inputFileName, | ||
| options.RotateImage, | ||
| options.PreserveMetaData); | ||
|
|
||
| if (processedStream != null) | ||
| { | ||
| // Determine the correct output extension based on the processed format | ||
| processedStream.Position = 0; | ||
| var outputExtension = ImageProcessor.DetermineOutputExtension(processedStream, options.CompressionQuality, inputFileName); | ||
| var originalExtension = System.IO.Path.GetExtension(imagePath); | ||
|
|
||
| // If format changed (e.g., PNG -> JPEG), use new extension | ||
| string outputPath = imagePath; | ||
| if (!string.Equals(outputExtension, originalExtension, StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| outputPath = System.IO.Path.ChangeExtension(imagePath, outputExtension); | ||
| } | ||
|
|
||
| if (preserveSource) | ||
| { | ||
| var outputFile = FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, Guid.NewGuid().ToString("N") + outputExtension); | ||
| outputPath = outputFile.AbsolutePath; | ||
| } | ||
| else | ||
| { | ||
| // Delete original file first | ||
| try | ||
| { originalFile.Delete(); } | ||
| catch { } | ||
| } | ||
|
|
||
| // Write processed image to output path with correct extension | ||
| using var outputStream = File.Create(outputPath); | ||
| processedStream.Position = 0; | ||
| await processedStream.CopyToAsync(outputStream); | ||
|
|
||
| return outputPath; | ||
| } | ||
|
|
||
| // If ImageProcessor returns null (e.g., on .NET Standard), ImageProcessor.IsProcessingNeeded would have returned false, | ||
| // so we shouldn't reach this point. Return original path as fallback. | ||
| return imagePath; | ||
| } | ||
| catch | ||
| { | ||
| // If processing fails, return original path | ||
| } | ||
|
|
||
| return imagePath; | ||
| } | ||
|
|
||
| async Task<string> CaptureVideoAsync(Intent captureIntent) | ||
| { | ||
| // On Android 12 (API 31-32), the camera app creates the video in MediaStore as a pending item. | ||
|
|
@@ -761,17 +578,8 @@ void OnResult(Intent resultIntent) | |
|
|
||
| foreach (var path in tempResultList) | ||
| { | ||
| string processedPath = path; | ||
|
|
||
| // Apply rotation if needed | ||
| if (ImageProcessor.IsRotationNeeded(options)) | ||
| await RotateImageInPlace(processedPath, options); | ||
|
|
||
| // Apply compression/resizing if needed | ||
| if (ImageProcessor.IsProcessingNeeded(options?.MaximumWidth, options?.MaximumHeight, options?.CompressionQuality ?? 100)) | ||
| { | ||
| processedPath = await CompressImageIfNeeded(processedPath, options); | ||
| } | ||
| // Apply rotation and/or compression if needed | ||
| var processedPath = await ProcessImage(path, options); | ||
|
|
||
| resultList.Add(new FileResult(processedPath)); | ||
| } | ||
|
|
@@ -784,5 +592,110 @@ void OnResult(Intent resultIntent) | |
| return []; | ||
| } | ||
| } | ||
|
|
||
| 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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[minor] Public API Surface (internal naming) — |
||
| { | ||
| if (string.IsNullOrEmpty(imagePath) || !File.Exists(imagePath)) | ||
| return imagePath; | ||
|
|
||
| var needsRotation = options.RotateImage; | ||
| var needsProcessing = ImageProcessor.IsProcessingNeeded(options.MaximumWidth, options.MaximumHeight, options.CompressionQuality); | ||
|
|
||
| // Nothing to do - return the picked path untouched. | ||
| if (!needsRotation && !needsProcessing) | ||
| return imagePath; | ||
|
|
||
| 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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[minor] Performance-Critical Path / memory — The rotated |
||
| 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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Logic and Correctness — When |
||
| { | ||
| 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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[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:
Previously Suggested fix: only change the extension when
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Concrete scenario: user picks a modern-device This exposure is new to this PR: before the change, |
||
| 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); | ||
|
mattleibow marked this conversation as resolved.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Android Platform / Cross-Platform Consistency — The terminal output is always written to |
||
| 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 && | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Memory Leak Prevention / cache growth — Deleting only the input file leaves its containing directory behind forever.
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 |
||
| FileSystemUtils.IsMauiOwnedTemporaryFile(imagePath) && | ||
| !string.Equals(imagePath, outputPath, StringComparison.Ordinal)) | ||
| { | ||
| try | ||
| { File.Delete(imagePath); } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Regression Prevention / disk hygiene — |
||
| catch { } | ||
| } | ||
|
|
||
| return outputPath; | ||
| } | ||
| finally | ||
| { | ||
| currentStream.Dispose(); | ||
| } | ||
| } | ||
| catch | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Null Safety and Defensive Coding — A failure after If
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Null Safety / Defensive Coding — This blanket |
||
| { | ||
| // If processing fails, leave the picked source untouched and return its path. | ||
| } | ||
|
|
||
| return imagePath; | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Suggestion — Android Platform / Performance: each call allocates a
Java.IO.File[], crosses the JNI bridge forApplication.Contexttwice, and leaks three undisposedJava.IO.Filepeers (new Java.IO.File(path)plus one per root) whoseCanonicalPathalso hits JNI.Per
android.instructions.md, cacheContextin 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. hoistvar context = Application.Context;, compute the two candidate roots intostrings, and compare against the single canonicalized input path. Wrapping theJava.IO.Fileinstances inusingwould also release the peers deterministically.