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.
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.