Add SaveToGallery option to MediaPickerOptions for capture operations#34641
Add SaveToGallery option to MediaPickerOptions for capture operations#34641Copilot wants to merge 4 commits into
Conversation
… implementations Add a new `SaveToGallery` boolean property (default: false) to `MediaPickerOptions`. When true during capture operations, the captured image/video is saved to the device photo gallery and the necessary permissions are requested. iOS/macOS: Uses PHPhotoLibrary.PerformChangesAsync with PHAssetChangeRequest. Requests PhotosAddOnly permission only when SaveToGallery is true. Android: Uses MediaStore ContentResolver insert for API 29+. Requests StorageWrite permission only when SaveToGallery is true on older API levels. Windows/Tizen: Property is ignored (not supported, documented in API docs). Fixes #34246 Co-authored-by: jfversluis <939291+jfversluis@users.noreply.github.com> Agent-Logs-Url: https://github.com/dotnet/maui/sessions/31e2becf-8873-4347-a7a7-bb89d6e3647f
…cleanup, unique temp paths - Use file extension for MIME type detection instead of hardcoded values - Clean up pending MediaStore entries when OutputStream is null on Android - Use unique temp file names in iOS to avoid collisions - Add XML doc comments to save methods - Fix nullable annotation in non-nullable context Co-authored-by: jfversluis <939291+jfversluis@users.noreply.github.com> Agent-Logs-Url: https://github.com/dotnet/maui/sessions/31e2becf-8873-4347-a7a7-bb89d6e3647f
|
@copilot this should target the net11.0 branch. Update the target and rebase on that. |
…e-image-save-option
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 34641Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 34641" |
Co-authored-by: jfversluis <939291+jfversluis@users.noreply.github.com> Agent-Logs-Url: https://github.com/dotnet/maui/sessions/28acf19d-68b5-463b-9de7-4e822741ce9a
Done — I merged Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
|
Will probably have a conflict with #34287 when that is merged up to the net11.0 branch. Afterwards I think this will be nice to have for .NET 11. |
There was a problem hiding this comment.
Pull request overview
Adds an opt-in SaveToGallery flag to MediaPickerOptions so capture operations can avoid requesting gallery/photo-library permissions unless the app explicitly asks to save captured media to the user’s gallery.
Changes:
- Introduces
MediaPickerOptions.SaveToGallery(defaultfalse) with platform behavior documented in XML docs. - Updates iOS and Android capture flows to conditionally request permissions and save captured media to the gallery/library when enabled.
- Updates PublicAPI unshipped files and the Essentials sample UI to expose a “Save to Gallery” toggle.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Essentials/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt | Adds SaveToGallery getter/setter to the netstandard API surface. |
| src/Essentials/src/PublicAPI/net/PublicAPI.Unshipped.txt | Adds SaveToGallery getter/setter to the net API surface. |
| src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt | Adds SaveToGallery getter/setter to the Windows API surface. |
| src/Essentials/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt | Adds SaveToGallery getter/setter to the Tizen API surface. |
| src/Essentials/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt | Adds SaveToGallery getter/setter to the MacCatalyst API surface. |
| src/Essentials/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt | Adds SaveToGallery getter/setter to the iOS API surface. |
| src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt | Adds SaveToGallery getter/setter to the Android API surface. |
| src/Essentials/src/MediaPicker/MediaPicker.shared.cs | Defines and documents the new SaveToGallery option on MediaPickerOptions. |
| src/Essentials/src/MediaPicker/MediaPicker.ios.cs | Gates PhotosAddOnly permission and saves captured media to the photo library only when requested. |
| src/Essentials/src/MediaPicker/MediaPicker.android.cs | Gates StorageWrite permission (<29) and saves captured media to MediaStore when requested. |
| src/Essentials/samples/Samples/ViewModel/MediaPickerViewModel.cs | Adds a viewmodel toggle and wires SaveToGallery into capture calls. |
| src/Essentials/samples/Samples/View/MediaPickerPage.xaml | Adds a UI switch to control “Save to Gallery (Capture)”. |
| /// Saves the captured media file to the device's gallery using MediaStore. | ||
| /// On API 29+, uses scoped storage with IsPending flag. On older versions, uses direct file copy. | ||
| /// </summary> | ||
| static async Task SaveToGalleryAsync(string filePath, bool isPhoto) | ||
| { | ||
| try | ||
| { | ||
| var context = Application.Context; | ||
| var contentResolver = context.ContentResolver; | ||
| if (contentResolver is null) | ||
| return; | ||
|
|
||
| var fileName = System.IO.Path.GetFileName(filePath); | ||
| var extension = System.IO.Path.GetExtension(filePath)?.ToLowerInvariant(); | ||
| var mimeType = GetMimeType(extension, isPhoto); | ||
|
|
||
| var contentValues = new ContentValues(); | ||
| contentValues.Put(MediaStore.IMediaColumns.DisplayName, fileName); | ||
| contentValues.Put(MediaStore.IMediaColumns.MimeType, mimeType); | ||
|
|
||
| if (OperatingSystem.IsAndroidVersionAtLeast(29)) | ||
| { | ||
| contentValues.Put(MediaStore.IMediaColumns.RelativePath, | ||
| isPhoto ? global::Android.OS.Environment.DirectoryPictures : global::Android.OS.Environment.DirectoryMovies); | ||
| contentValues.Put(MediaStore.IMediaColumns.IsPending, 1); | ||
| } | ||
|
|
||
| var collection = isPhoto | ||
| ? MediaStore.Images.Media.ExternalContentUri | ||
| : MediaStore.Video.Media.ExternalContentUri; | ||
|
|
||
| var insertUri = contentResolver.Insert(collection, contentValues); | ||
|
|
||
| if (insertUri is not null) | ||
| { | ||
| using var outputStream = contentResolver.OpenOutputStream(insertUri); | ||
| if (outputStream is not null) | ||
| { | ||
| using var inputStream = File.OpenRead(filePath); | ||
| await inputStream.CopyToAsync(outputStream); | ||
| } | ||
| else | ||
| { | ||
| // Clean up the pending entry if we couldn't write to it | ||
| contentResolver.Delete(insertUri, null, null); | ||
| return; | ||
| } | ||
|
|
||
| if (OperatingSystem.IsAndroidVersionAtLeast(29)) | ||
| { | ||
| contentValues.Clear(); | ||
| contentValues.Put(MediaStore.IMediaColumns.IsPending, 0); | ||
| contentResolver.Update(insertUri, contentValues, null, null); | ||
| } | ||
| } |
There was a problem hiding this comment.
The XML summary says that on Android versions below API 29 this method uses a direct file copy, but the implementation still uses a MediaStore insert + OpenOutputStream for all API levels. Please either adjust the comment to match the actual approach, or implement the documented pre-29 behavior so the comment stays accurate (and make sure the pre-29 path results in the media being visible in the gallery).
| /// Saves the captured media file to the device's gallery using MediaStore. | |
| /// On API 29+, uses scoped storage with IsPending flag. On older versions, uses direct file copy. | |
| /// </summary> | |
| static async Task SaveToGalleryAsync(string filePath, bool isPhoto) | |
| { | |
| try | |
| { | |
| var context = Application.Context; | |
| var contentResolver = context.ContentResolver; | |
| if (contentResolver is null) | |
| return; | |
| var fileName = System.IO.Path.GetFileName(filePath); | |
| var extension = System.IO.Path.GetExtension(filePath)?.ToLowerInvariant(); | |
| var mimeType = GetMimeType(extension, isPhoto); | |
| var contentValues = new ContentValues(); | |
| contentValues.Put(MediaStore.IMediaColumns.DisplayName, fileName); | |
| contentValues.Put(MediaStore.IMediaColumns.MimeType, mimeType); | |
| if (OperatingSystem.IsAndroidVersionAtLeast(29)) | |
| { | |
| contentValues.Put(MediaStore.IMediaColumns.RelativePath, | |
| isPhoto ? global::Android.OS.Environment.DirectoryPictures : global::Android.OS.Environment.DirectoryMovies); | |
| contentValues.Put(MediaStore.IMediaColumns.IsPending, 1); | |
| } | |
| var collection = isPhoto | |
| ? MediaStore.Images.Media.ExternalContentUri | |
| : MediaStore.Video.Media.ExternalContentUri; | |
| var insertUri = contentResolver.Insert(collection, contentValues); | |
| if (insertUri is not null) | |
| { | |
| using var outputStream = contentResolver.OpenOutputStream(insertUri); | |
| if (outputStream is not null) | |
| { | |
| using var inputStream = File.OpenRead(filePath); | |
| await inputStream.CopyToAsync(outputStream); | |
| } | |
| else | |
| { | |
| // Clean up the pending entry if we couldn't write to it | |
| contentResolver.Delete(insertUri, null, null); | |
| return; | |
| } | |
| if (OperatingSystem.IsAndroidVersionAtLeast(29)) | |
| { | |
| contentValues.Clear(); | |
| contentValues.Put(MediaStore.IMediaColumns.IsPending, 0); | |
| contentResolver.Update(insertUri, contentValues, null, null); | |
| } | |
| } | |
| /// Saves the captured media file to the device's gallery. | |
| /// On API 29+, uses MediaStore with scoped storage and IsPending flag. On older versions, uses direct file copy. | |
| /// </summary> | |
| static async Task SaveToGalleryAsync(string filePath, bool isPhoto) | |
| { | |
| try | |
| { | |
| var context = Application.Context; | |
| if (context is null) | |
| return; | |
| var fileName = System.IO.Path.GetFileName(filePath); | |
| var extension = System.IO.Path.GetExtension(filePath)?.ToLowerInvariant(); | |
| var mimeType = GetMimeType(extension, isPhoto); | |
| if (OperatingSystem.IsAndroidVersionAtLeast(29)) | |
| { | |
| var contentResolver = context.ContentResolver; | |
| if (contentResolver is null) | |
| return; | |
| var contentValues = new ContentValues(); | |
| contentValues.Put(MediaStore.IMediaColumns.DisplayName, fileName); | |
| contentValues.Put(MediaStore.IMediaColumns.MimeType, mimeType); | |
| contentValues.Put(MediaStore.IMediaColumns.RelativePath, | |
| isPhoto ? global::Android.OS.Environment.DirectoryPictures : global::Android.OS.Environment.DirectoryMovies); | |
| contentValues.Put(MediaStore.IMediaColumns.IsPending, 1); | |
| var collection = isPhoto | |
| ? MediaStore.Images.Media.ExternalContentUri | |
| : MediaStore.Video.Media.ExternalContentUri; | |
| var insertUri = contentResolver.Insert(collection, contentValues); | |
| if (insertUri is not null) | |
| { | |
| using var outputStream = contentResolver.OpenOutputStream(insertUri); | |
| if (outputStream is not null) | |
| { | |
| using var inputStream = File.OpenRead(filePath); | |
| await inputStream.CopyToAsync(outputStream); | |
| } | |
| else | |
| { | |
| // Clean up the pending entry if we couldn't write to it | |
| contentResolver.Delete(insertUri, null, null); | |
| return; | |
| } | |
| contentValues.Clear(); | |
| contentValues.Put(MediaStore.IMediaColumns.IsPending, 0); | |
| contentResolver.Update(insertUri, contentValues, null, null); | |
| } | |
| } | |
| else | |
| { | |
| // Pre-29: direct file copy to public external storage, then trigger media scan | |
| var directory = isPhoto | |
| ? global::Android.OS.Environment.GetExternalStoragePublicDirectory(global::Android.OS.Environment.DirectoryPictures) | |
| : global::Android.OS.Environment.GetExternalStoragePublicDirectory(global::Android.OS.Environment.DirectoryMovies); | |
| if (directory is null) | |
| return; | |
| var directoryPath = directory.AbsolutePath; | |
| if (string.IsNullOrEmpty(directoryPath)) | |
| return; | |
| Directory.CreateDirectory(directoryPath); | |
| var destinationPath = System.IO.Path.Combine(directoryPath, fileName); | |
| File.Copy(filePath, destinationPath, overwrite: true); | |
| // Ensure the copied media appears in the gallery | |
| Android.Media.MediaScannerConnection.ScanFile( | |
| context, | |
| new[] { destinationPath }, | |
| new[] { mimeType }, | |
| null); | |
| } |
| using var stream = await fileResult.OpenReadAsync(); | ||
| var extension = System.IO.Path.GetExtension(fileResult.FileName); | ||
| var tempPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"{Guid.NewGuid()}{extension}"); | ||
| using (var fileStream = File.Create(tempPath)) | ||
| { | ||
| stream.Position = 0; | ||
| await stream.CopyToAsync(fileStream); | ||
| } |
There was a problem hiding this comment.
If the stream copy or PHPhotoLibrary.PerformChangesAsync throws, tempPath won’t be deleted because cleanup happens only after the await. Consider using a try/finally (or similar) so the temporary file is always removed even on failure. Also consider guarding stream.Position = 0 with stream.CanSeek (or removing it) to avoid NotSupportedException for non-seekable streams.
AI code review for net11.0 targetVerdict: Needs changes / discussion Independent review of the diff at the PR head (not a checkout). Non-approval, automated review comment — human approval still required. Findings:
CI: Failing across all Confidence: Medium (code reviewed from diff; CI cause unverifiable due to expired logs). |
kubaflo
left a comment
There was a problem hiding this comment.
Verdict: NEEDS_CHANGES
All four independent model reviews returned NEEDS_CHANGES (4/4 consensus). The API shape is sound — MediaPickerOptions.SaveToGallery defaults to false (non-regressing), is mirrored across every PublicAPI.Unshipped.txt, and the iOS conditional PhotosAddOnly request correctly resolves #34246. The headline blocker is the Android gallery-save helper: it publishes the MediaStore entry before the output stream is flushed and orphans IsPending=1 rows when the copy fails — confirmed against the code at MediaPicker.android.cs:458-484.
Key findings
- [error] Android
SaveToGalleryAsyncordering + cleanup —using var outputStream(line 460) disposes only at theifblock close (line 479), afterUpdate(IsPending=0)at line 477, so the row is published before the stream is flushed (0-byte/truncated entries on some OEMs). ACopyToAsyncthrow (line 464) hits the log-onlycatch(line 481), leaving a permanently-pending, invisible row that is never deleted. - [warning] Silent failure despite explicit opt-in — both helpers only
Debug.WriteLineon failure (stripped in Release); withSaveToGallery=truea failed save still returns a successfulFileResultwith no signal (MediaPicker.android.cs:483,MediaPicker.ios.cs:524). - [warning] iOS temp file leak —
File.Delete(tempPath)runs only on the success path insidetry; an exception orphans the temp copy. Move cleanup tofinally, and broaden theIOException-only catch (MediaPicker.ios.cs:520).
Dropped / not surfaced
- gemini
stream.Position = 0(ios.cs:502) — redundant but harmless; captured-file streams are seekable, so the "throws on non-seekable" claim does not apply. Nitpick, dropped. - gemini "PerformChangesAsync return value ignored" (ios.cs:508) — folded into the silent-failure finding; the precise throw-vs-return binding semantics could not be verified, so it is not posted as a standalone claim.
CI / merge state
- maui-pr CI is failing (Windows + macOS build/pack and Helix unit-test legs all red) — see Build #1352120. Blocks merge.
- MauiBot reports an active merge conflict with the
net11.0target branch — the branch must be rebased/merged and conflicts resolved before this can land.
|
|
||
| if (insertUri is not null) | ||
| { | ||
| using var outputStream = contentResolver.OpenOutputStream(insertUri); |
There was a problem hiding this comment.
Gallery save can publish a row before the stream is flushed, and orphans pending rows on failure. Two related defects in the API 29+ path of SaveToGalleryAsync:
-
Publish-before-flush ordering.
using var outputStreamhere is a using-declaration scoped to the enclosingif (insertUri is not null)block, so the stream isn't flushed/disposed until the block closes (line 479) — aftercontentResolver.Update(insertUri, ...)clearsIsPending=0at line 477. The entry is therefore published before its output stream is guaranteed flushed/closed, which on some OEM ROMs produces a 0-byte or truncated gallery item. Wrap the copy in an explicitusing (var outputStream = ...) { ... }block that closes before clearingIsPending. -
Orphaned pending row on failure. If
CopyToAsync(line 464) throws (disk full, I/O error), control jumps to thecatchat line 481 which only logs; the row inserted withIsPending=1(line 449) is never set to 0 nor deleted, leaving a permanently-pending entry that stays invisible in the gallery. DeleteinsertUrion the failure path — the existingelsebranch already does this whenOpenOutputStreamreturns null, and the copy-throws case needs the same cleanup.
| } | ||
| catch (Exception ex) | ||
| { | ||
| System.Diagnostics.Debug.WriteLine($"Failed to save to gallery: {ex.Message}"); |
There was a problem hiding this comment.
Save failures are silently swallowed despite an explicit opt-in. When SaveToGallery=true and the save fails (permission revoked after the check, disk full, MediaStore error), this catch only Debug.WriteLines — which is stripped from Release builds — and CaptureAsync still returns a successful FileResult with no indication the gallery save never happened. Since the caller explicitly opted in, the failure should be surfaced (e.g. via the framework logger, or by propagating/returning a status) rather than no-opped. The same applies to the iOS helper at MediaPicker.ios.cs:524.
| } | ||
| }); | ||
|
|
||
| try { File.Delete(tempPath); } catch (IOException) { } |
There was a problem hiding this comment.
Temp file leaks on the exception path. File.Delete(tempPath) runs only on the success path inside try; if OpenReadAsync/CopyToAsync or PerformChangesAsync throws, control jumps to the catch at line 522 and the temp copy created at line 499 is orphaned on disk. Move the cleanup into a finally block (with a broad catch) so the temp file is always removed. Catching only IOException on the delete is also too narrow (e.g. UnauthorizedAccessException would leave the file behind).
SaveToGalleryproperty toMediaPickerOptions(defaultfalse)PhotosAddOnlypermission and save to photo library only whenSaveToGallery=trueStorageWrite(API < 29) /MediaStoreinsert (API 29+) only whenSaveToGallery=true.ios.csfile)PublicAPI.Unshipped.txtfiles with new API entrySaveToGallerytogglenet11.0branch into PR — PR base should be changed tonet11.0Fixes #34246
⌨️ Start Copilot coding agent tasks without leaving your editor — available in VS Code, Visual Studio, JetBrains IDEs and Eclipse.