diff --git a/src/Essentials/src/MediaPicker/MediaPicker.ios.cs b/src/Essentials/src/MediaPicker/MediaPicker.ios.cs index ae98abcaa795..4f31f36b712d 100644 --- a/src/Essentials/src/MediaPicker/MediaPicker.ios.cs +++ b/src/Essentials/src/MediaPicker/MediaPicker.ios.cs @@ -283,17 +283,17 @@ static async Task> PickerResultsToMediaFiles(PHPickerResult[] r { using var originalStream = await result.OpenReadAsync(); using var rotatedStream = await ImageProcessor.RotateImageAsync(originalStream, result.FileName); - + // Create a temp file for the rotated image var tempFileName = $"{Guid.NewGuid()}{Path.GetExtension(result.FileName)}"; var tempFilePath = Path.Combine(Path.GetTempPath(), tempFileName); - + using (var fileStream = File.Create(tempFilePath)) { rotatedStream.Position = 0; await rotatedStream.CopyToAsync(fileStream); } - + var rotatedResult = new FileResult(tempFilePath) { FileName = result.FileName, @@ -316,7 +316,7 @@ static async Task> PickerResultsToMediaFiles(PHPickerResult[] r var compressedResults = new List(); foreach (var result in fileResults) { - var compressedResult = await CompressedUIImageFileResult.CreateCompressedFromFileResult(result, options?.MaximumWidth, options?.MaximumHeight, options?.CompressionQuality ?? 100, options?.RotateImage ?? false, options?.PreserveMetaData ?? true); + var compressedResult = new PHPickerProcessedFileResult(result, options?.MaximumWidth, options?.MaximumHeight, options?.CompressionQuality ?? 100, options?.RotateImage ?? false, options?.PreserveMetaData ?? true); compressedResults.Add(compressedResult); } return compressedResults; @@ -365,7 +365,7 @@ static FileResult DictionaryToMediaFile(NSDictionary info, MediaPickerOptions op if (!assetUrl.Scheme.Equals("assets-library", StringComparison.OrdinalIgnoreCase)) { var docResult = new UIDocumentFileResult(assetUrl); - + // Apply rotation if needed and this is a photo if (ImageProcessor.IsRotationNeeded(options) && IsImageFile(docResult.FileName)) { @@ -380,7 +380,7 @@ static FileResult DictionaryToMediaFile(NSDictionary info, MediaPickerOptions op // If rotation fails, continue with the original file } } - + return docResult; } @@ -411,7 +411,7 @@ static FileResult DictionaryToMediaFile(NSDictionary info, MediaPickerOptions op { img = img.NormalizeOrientation(); } - + return new CompressedUIImageFileResult(img, null, options?.MaximumWidth, options?.MaximumHeight, options?.CompressionQuality ?? 100); } } @@ -423,7 +423,7 @@ static FileResult DictionaryToMediaFile(NSDictionary info, MediaPickerOptions op string originalFilename = PHAssetResource.GetAssetResources(phAsset).FirstOrDefault()?.OriginalFilename; var assetResult = new PHAssetFileResult(assetUrl, phAsset, originalFilename); - + // Apply rotation if needed and this is a photo if (ImageProcessor.IsRotationNeeded(options) && IsImageFile(assetResult.FileName)) { @@ -438,41 +438,41 @@ static FileResult DictionaryToMediaFile(NSDictionary info, MediaPickerOptions op // If rotation fails, continue with the original file } } - + return assetResult; } - + // Helper method to check if a file is an image based on extension static bool IsImageFile(string fileName) { if (string.IsNullOrEmpty(fileName)) return false; - + var ext = Path.GetExtension(fileName)?.ToLowerInvariant(); return ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".heic" || ext == ".heif"; } - + // Helper method to rotate an image file static async Task RotateImageFile(FileResult original) { if (original == null) return null; - + try { using var originalStream = await original.OpenReadAsync(); using var rotatedStream = await ImageProcessor.RotateImageAsync(originalStream, original.FileName); - + // Create a temp file for the rotated image var tempFileName = $"{Guid.NewGuid()}{Path.GetExtension(original.FileName)}"; var tempFilePath = Path.Combine(Path.GetTempPath(), tempFileName); - + using (var fileStream = File.Create(tempFilePath)) { rotatedStream.Position = 0; await rotatedStream.CopyToAsync(fileStream); } - + return new FileResult(tempFilePath, original.FileName); } catch (Exception ex) @@ -481,7 +481,7 @@ static async Task RotateImageFile(FileResult original) return original; } } - + class PhotoPickerDelegate : UIImagePickerControllerDelegate { public Action CompletedHandler { get; set; } @@ -739,4 +739,123 @@ public void Dispose() GC.SuppressFinalize(this); } } + + /// + /// Wrapper that applies compression lazily when the stream is opened. + /// This avoids iOS resource limits when processing multiple photos. + /// + class PHPickerProcessedFileResult : FileResult, IDisposable + { + readonly FileResult _originalResult; + readonly int? _maximumWidth; + readonly int? _maximumHeight; + readonly int _compressionQuality; + readonly bool _rotateImage; + readonly bool _preserveMetaData; + + // Cached result of the first call to PlatformOpenReadAsync to avoid re-processing + byte[] _cachedData; + + internal PHPickerProcessedFileResult(FileResult originalResult, int? maximumWidth, int? maximumHeight, int compressionQuality, bool rotateImage, bool preserveMetaData) + : base() + { + _originalResult = originalResult; + _maximumWidth = maximumWidth; + _maximumHeight = maximumHeight; + _compressionQuality = compressionQuality; + _rotateImage = rotateImage; + _preserveMetaData = preserveMetaData; + + // Copy metadata from original, adjusting extension for compressed output + var originalFileName = originalResult.FileName; + var originalFullPath = originalResult.FullPath; + var originalContentType = originalResult.ContentType; + + // Preserve the original format: PNG stays PNG, everything else compresses to JPEG. + // When no compression is applied (quality == 100), preserve the original extension as-is. + var originalWasPng = !string.IsNullOrEmpty(originalFileName) && + Path.GetExtension(originalFileName).Equals(".png", StringComparison.OrdinalIgnoreCase); + string outputExtension; + if (compressionQuality == 100) + outputExtension = Path.GetExtension(originalFileName ?? string.Empty); + else if (originalWasPng) + outputExtension = ".png"; + else + outputExtension = ".jpg"; + + FileName = !string.IsNullOrEmpty(originalFileName) && !string.IsNullOrEmpty(outputExtension) + ? Path.ChangeExtension(originalFileName, outputExtension) + : originalFileName; + + FullPath = !string.IsNullOrEmpty(originalFullPath) && !string.IsNullOrEmpty(outputExtension) + ? Path.ChangeExtension(originalFullPath, outputExtension) + : originalFullPath; + + ContentType = string.Equals(outputExtension, ".png", StringComparison.OrdinalIgnoreCase) + ? "image/png" + : string.Equals(outputExtension, ".jpg", StringComparison.OrdinalIgnoreCase) || string.Equals(outputExtension, ".jpeg", StringComparison.OrdinalIgnoreCase) + ? "image/jpeg" + : originalContentType; + } + + internal override async Task PlatformOpenReadAsync() + { + // Return cached result on subsequent calls to avoid re-processing + if (_cachedData is not null) + return new MemoryStream(_cachedData, writable: false); + + // Load the original stream into memory once to avoid multiple expensive NSItemProvider loads. + byte[] originalData; + using (var originalStream = await _originalResult.OpenReadAsync()) + using (var buffer = new MemoryStream()) + { + await originalStream.CopyToAsync(buffer); + originalData = buffer.ToArray(); + } + + Stream processedStream = null; + try + { + // processedStream is always an independent MemoryStream from ImageProcessor.ProcessImageAsync; + // it does not reference or depend on originalStream after this call completes. + using var originalForProcessing = new MemoryStream(originalData, writable: false); + processedStream = await ImageProcessor.ProcessImageAsync( + originalForProcessing, + _maximumWidth, + _maximumHeight, + _compressionQuality, + _originalResult.FileName, + _rotateImage, + _preserveMetaData); + } + catch + { + // Swallow processing exceptions and fall back to the original data. + processedStream = null; + } + + if (processedStream is null) + { + // Fall back to the original data if processing failed or returned null. + _cachedData = originalData; + } + else + { + using (processedStream) + using (var buffer = new MemoryStream()) + { + await processedStream.CopyToAsync(buffer); + _cachedData = buffer.ToArray(); + } + } + + return new MemoryStream(_cachedData, writable: false); + } + + public void Dispose() + { + (_originalResult as IDisposable)?.Dispose(); + GC.SuppressFinalize(this); + } + } }