diff --git a/src/Essentials/src/FileSystem/FileSystemUtils.android.cs b/src/Essentials/src/FileSystem/FileSystemUtils.android.cs index b97430492ebb..c6362e4c0513 100644 --- a/src/Essentials/src/FileSystem/FileSystemUtils.android.cs +++ b/src/Essentials/src/FileSystem/FileSystemUtils.android.cs @@ -53,6 +53,40 @@ public static Java.IO.File GetTemporaryFile(Java.IO.File root, string fileName) return tmpFile; } + // Determines whether a path is a temporary file that MAUI itself created via + // GetTemporaryFile - i.e. one that lives under the "//" + // folder that only GetTemporaryFile ever creates. This is a reliable ownership marker: + // files picked from the gallery, an SD card, another app's shared storage, or resolved + // to a raw physical path are never located under that folder, so callers can safely + // clean up an owned temporary input without any risk of touching a user-owned source. + internal static bool IsMauiOwnedTemporaryFile(string path) + { + if (string.IsNullOrEmpty(path)) + return false; + + try + { + var canonicalPath = new Java.IO.File(path).CanonicalPath; + + foreach (var root in new[] { Application.Context.CacheDir, Application.Context.ExternalCacheDir }) + { + if (root is null) + continue; + + var ownedRoot = new Java.IO.File(root, EssentialsFolderHash).CanonicalPath + Java.IO.File.Separator; + if (canonicalPath.StartsWith(ownedRoot, StringComparison.Ordinal)) + return true; + } + } + catch + { + // If the path cannot be canonicalized, err on the side of caution and never treat + // it as owned - callers use this to decide whether to delete, so false is the safe default. + } + + return false; + } + public static string EnsurePhysicalPath(AndroidUri uri, bool requireExtendedAccess = true) { // if this is a file, use that diff --git a/src/Essentials/src/MediaPicker/MediaPicker.android.cs b/src/Essentials/src/MediaPicker/MediaPicker.android.cs index bfbdaf136189..2d7e765ab371 100644 --- a/src/Essentials/src/MediaPicker/MediaPicker.android.cs +++ b/src/Essentials/src/MediaPicker/MediaPicker.android.cs @@ -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 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 ProcessPhotoPreservingSourceAsync(string imagePath, PersistedPhotoProcessingOptions options) - { - if (imagePath is null) - { - return null; - } + internal static Task 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 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 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 CompressImageIfNeeded(string imagePath, MediaPickerOptions options, bool preserveSource = false) - => CompressImageIfNeeded(imagePath, GetPhotoProcessingOptions(options), preserveSource); - - static async Task 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 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 ProcessImage(string imagePath, MediaPickerOptions options) + => ProcessImage(imagePath, GetPhotoProcessingOptions(options)); + + internal static async Task ProcessImage(string imagePath, PersistedPhotoProcessingOptions options, bool preserveSource = false) + { + 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); + 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(); + } + } + catch + { + // If processing fails, leave the picked source untouched and return its path. + } + + return imagePath; + } } } diff --git a/src/Essentials/test/DeviceTests/Tests/Android/MediaPicker_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Android/MediaPicker_Tests.cs new file mode 100644 index 000000000000..35ecc1469df3 --- /dev/null +++ b/src/Essentials/test/DeviceTests/Tests/Android/MediaPicker_Tests.cs @@ -0,0 +1,183 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Android.App; +using Android.Graphics; +using Microsoft.Maui.Media; +using Microsoft.Maui.Storage; +using Xunit; +using Path = System.IO.Path; + +namespace Microsoft.Maui.Essentials.DeviceTests.Shared +{ + [Category("MediaPicker")] + public class Android_MediaPicker_Tests + { + [Fact] + public Task ProcessImage_Rotation_DoesNotModifySource_And_WritesSingleCacheFile() + => AssertProcessedToCacheWithoutTouchingSource( + new MediaPickerOptions { RotateImage = true }); + + [Fact] + public Task ProcessImage_Compression_DoesNotModifySource_And_WritesSingleCacheFile() + => AssertProcessedToCacheWithoutTouchingSource( + new MediaPickerOptions { CompressionQuality = 50 }); + + [Fact] + public Task ProcessImage_RotationAndCompression_DoesNotModifySource_And_WritesSingleCacheFile() + => AssertProcessedToCacheWithoutTouchingSource( + new MediaPickerOptions { RotateImage = true, CompressionQuality = 50 }); + + [Fact] + public async Task ProcessImage_NoProcessingNeeded_ReturnsOriginalPath_Unchanged() + { + var baseName = "mp_noop_" + Guid.NewGuid().ToString("N"); + var sourcePath = CreateJpegOutsideCache(baseName + ".jpg"); + var originalBytes = File.ReadAllBytes(sourcePath); + + try + { + // No rotation, full quality, no resize -> there is nothing to process. + var options = new MediaPickerOptions { RotateImage = false, CompressionQuality = 100 }; + + var outputPath = await MediaPickerImplementation.ProcessImage(sourcePath, options); + + // The original path is returned as-is and the file is untouched. + Assert.Equal(sourcePath, outputPath); + Assert.True(File.Exists(sourcePath)); + Assert.Equal(originalBytes, File.ReadAllBytes(sourcePath)); + + // No cache copy is created when there is nothing to do. + Assert.Empty(FindCacheFiles(baseName)); + } + finally + { + SafeDelete(sourcePath); + DeleteCacheFiles(baseName); + } + } + + [Fact] + public async Task ProcessImage_MauiOwnedCacheInput_IsReplaced_NotOrphaned() + { + var baseName = "mp_owned_" + Guid.NewGuid().ToString("N"); + + // Create a MAUI-owned temporary cache file, exactly like a camera capture + // (CapturePhotoAsync) or a content:// URI that EnsurePhysicalPath copied into cache. + var ownedFile = FileSystemUtils.GetTemporaryFile(Application.Context.CacheDir, baseName + ".jpg"); + var inputPath = ownedFile.AbsolutePath; + WriteJpeg(inputPath); + + string outputPath = null; + try + { + var options = new MediaPickerOptions { CompressionQuality = 50 }; + + outputPath = await MediaPickerImplementation.ProcessImage(inputPath, options); + + // A new terminal cache file is produced under the app cache dir. + Assert.NotEqual(inputPath, outputPath); + Assert.True(File.Exists(outputPath)); + Assert.StartsWith(FileSystem.CacheDirectory, outputPath, StringComparison.Ordinal); + + // The MAUI-owned input is deleted - not left orphaned alongside the output. + Assert.False(File.Exists(inputPath)); + + // Exactly one terminal cache file remains for this image (the output). + var cacheCopies = FindCacheFiles(baseName); + Assert.Single(cacheCopies); + Assert.Equal(Path.GetFullPath(outputPath), Path.GetFullPath(cacheCopies[0])); + + // The original base filename is preserved (#33258). + Assert.Equal(baseName, Path.GetFileNameWithoutExtension(outputPath)); + } + finally + { + SafeDelete(inputPath); + SafeDelete(outputPath); + DeleteCacheFiles(baseName); + } + } + + static async Task AssertProcessedToCacheWithoutTouchingSource(MediaPickerOptions options) + { + var baseName = "mp_src_" + Guid.NewGuid().ToString("N"); + var sourcePath = CreateJpegOutsideCache(baseName + ".jpg"); + var originalBytes = File.ReadAllBytes(sourcePath); + + try + { + var outputPath = await MediaPickerImplementation.ProcessImage(sourcePath, options); + + // The picked source is left untouched - no in-place overwrite, no data loss. + Assert.True(File.Exists(sourcePath)); + Assert.Equal(originalBytes, File.ReadAllBytes(sourcePath)); + + // The processed image is a brand new file under the app cache dir. + Assert.NotEqual(sourcePath, outputPath); + Assert.True(File.Exists(outputPath)); + Assert.StartsWith(FileSystem.CacheDirectory, outputPath, StringComparison.Ordinal); + + // The original base filename is preserved (#33258); only the extension may change. + Assert.Equal(baseName, Path.GetFileNameWithoutExtension(outputPath)); + + // Exactly one terminal cache file is written - no orphaned intermediate, + // even when both rotation and compression are applied. + var cacheCopies = FindCacheFiles(baseName); + Assert.Single(cacheCopies); + Assert.Equal(Path.GetFullPath(outputPath), Path.GetFullPath(cacheCopies[0])); + } + finally + { + SafeDelete(sourcePath); + DeleteCacheFiles(baseName); + } + } + + static string CreateJpegOutsideCache(string fileName) + { + // AppDataDirectory (/data/user/0//files) is separate from the cache dir and + // stands in for a picked file that MAUI does not own. + var dir = Path.Combine(FileSystem.AppDataDirectory, "mediapicker-source-tests"); + Directory.CreateDirectory(dir); + + var filePath = Path.Combine(dir, fileName); + WriteJpeg(filePath); + return filePath; + } + + static void WriteJpeg(string filePath) + { + using var bitmap = Bitmap.CreateBitmap(64, 64, Bitmap.Config.Argb8888); + using var stream = File.Create(filePath); + bitmap.Compress(Bitmap.CompressFormat.Jpeg, 100, stream); + } + + static string[] FindCacheFiles(string baseName) + { + if (!Directory.Exists(FileSystem.CacheDirectory)) + return Array.Empty(); + + return Directory.GetFiles(FileSystem.CacheDirectory, baseName + ".*", SearchOption.AllDirectories); + } + + static void DeleteCacheFiles(string baseName) + { + foreach (var file in FindCacheFiles(baseName)) + SafeDelete(file); + } + + static void SafeDelete(string path) + { + try + { + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + File.Delete(path); + } + catch + { + // best-effort cleanup + } + } + } +}