Skip to content

Add SaveToGallery option to MediaPickerOptions for capture operations#34641

Open
Copilot wants to merge 4 commits intonet11.0from
copilot/add-capture-image-save-option
Open

Add SaveToGallery option to MediaPickerOptions for capture operations#34641
Copilot wants to merge 4 commits intonet11.0from
copilot/add-capture-image-save-option

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 25, 2026

  • Add SaveToGallery property to MediaPickerOptions (default false)
  • iOS: Request PhotosAddOnly permission and save to photo library only when SaveToGallery=true
  • Android: Request StorageWrite (API < 29) / MediaStore insert (API 29+) only when SaveToGallery=true
  • macOS/MacCatalyst: Uses same iOS code path (shared .ios.cs file)
  • Windows: Documented as not supported in API docs (property ignored)
  • Tizen/netstandard: Property ignored (no save-to-gallery support)
  • Update all PublicAPI.Unshipped.txt files with new API entry
  • Update Essentials sample to add SaveToGallery toggle
  • Merged net11.0 branch into PR — PR base should be changed to net11.0

Fixes #34246


⌨️ Start Copilot coding agent tasks without leaving your editor — available in VS Code, Visual Studio, JetBrains IDEs and Eclipse.

Copilot AI and others added 2 commits March 25, 2026 13:06
… 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
@jfversluis
Copy link
Copy Markdown
Member

@copilot this should target the net11.0 branch. Update the target and rebase on that.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 25, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 34641

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 34641"

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 25, 2026

@copilot this should target the net11.0 branch. Update the target and rebase on that.

Done — I merged origin/net11.0 into the branch and resolved PublicAPI conflicts (commit 5f4436b4). The branch now includes all net11.0 content. However, I'm unable to change the PR's base branch programmatically — could you update the base from main to net11.0 in the PR settings? Once changed, the diff should show only the ~12 files with the SaveToGallery changes.

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:

  • https://api.github.com/repos/dotnet/maui/pulls/34641
    • Triggering command: /usr/bin/curl curl -s -X PATCH -H Authorization: ****** Accept: application/vnd.github.v3&#43;json REDACTED -d {&#34;base&#34;:&#34;net11.0&#34;} (http block)
    • Triggering command: /usr/bin/curl curl -s -X PATCH -H Authorization: ****** -H Accept: application/vnd.github.v3&#43;json REDACTED -d {&#34;base&#34;:&#34;net11.0&#34;} (http block)
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node /home/REDACTED/work/_temp/ghcca-node/node/bin/node --enable-source-maps /home/REDACTED/work/_temp/copilot-developer-action-main/dist/index.js (http block)

If you need me to access, download, or install something from one of these locations, you can either:

@jfversluis jfversluis changed the base branch from main to net11.0 March 25, 2026 13:41
@jfversluis
Copy link
Copy Markdown
Member

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.

@jfversluis jfversluis added this to the .NET 11.0-preview4 milestone Mar 25, 2026
@jfversluis jfversluis marked this pull request as ready for review March 25, 2026 13:44
Copilot AI review requested due to automatic review settings March 25, 2026 13:44
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (default false) 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)”.

Comment on lines +425 to +479
/// 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);
}
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
/// 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);
}

Copilot uses AI. Check for mistakes.
Comment on lines +497 to +504
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);
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS MediaPicker CapturePhotoAsync without "PhotosAddOnly" permission

3 participants