Skip to content

Add SaveToGallery option to MediaPickerOptions for capture operations#34641

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

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

Conversation

Copilot AI commented Mar 25, 2026

Copy link
Copy Markdown
Contributor
  • 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

github-actions Bot commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

🚀 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"

Copilot AI commented Mar 25, 2026

Copy link
Copy Markdown
Contributor Author

@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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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);
}
}

Copilot AI Mar 25, 2026

Copy link

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);
}

Copilot AI Mar 25, 2026

Copy link

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

kubaflo commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

AI code review for net11.0 target

Verdict: 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:

  • New public API MediaPickerOptions.SaveToGallery (default false) is added consistently across all PublicAPI.Unshipped.txt ring files, with good XML doc on per-platform behavior. 👍
  • Behavior change beyond the new feature (Android): the storage-write permission gate changes from !IsAndroidVersionAtLeast(33) (requested for every capture on API < 33) to options?.SaveToGallery == true && !IsAndroidVersionAtLeast(29). This alters permission behavior for all capture calls, not just gallery saves. It's likely an improvement (default capture writes to app-private storage), but it's an unscoped behavior change reviewers should explicitly sign off on.
  • Behavior change (iOS): PhotosAddOnly permission is now requested only when SaveToGallery == true (previously requested on every capture). Reasonable improvement, but a behavior change.
  • Silent failure: both SaveToGalleryAsync (Android) and SaveToPhotoLibraryAsync (iOS) wrap everything in try/catch and only Debug.WriteLine on failure. If saving fails (e.g. permission denied), the capture still returns success and the gallery save silently no-ops. Consider surfacing the failure or at least logging via the framework logger rather than Debug.WriteLine.
  • Minor (iOS): the temp file is deleted only on the success path inside the try; an exception before File.Delete leaks the temp copy.

CI: Failing across all maui-pr build legs. The failing build (1352120, 2026-03-25) is retention-expired, so logs are no longer available. Because MediaPicker.shared.cs compiles on every platform, a real failure would block all legs — this needs a rebase + fresh run to confirm it builds. Merge readiness cannot be confirmed from the current stale CI.

Confidence: Medium (code reviewed from diff; CI cause unverifiable due to expired logs).

@kubaflo kubaflo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 SaveToGalleryAsync ordering + cleanupusing var outputStream (line 460) disposes only at the if block close (line 479), after Update(IsPending=0) at line 477, so the row is published before the stream is flushed (0-byte/truncated entries on some OEMs). A CopyToAsync throw (line 464) hits the log-only catch (line 481), leaving a permanently-pending, invisible row that is never deleted.
  • [warning] Silent failure despite explicit opt-in — both helpers only Debug.WriteLine on failure (stripped in Release); with SaveToGallery=true a failed save still returns a successful FileResult with no signal (MediaPicker.android.cs:483, MediaPicker.ios.cs:524).
  • [warning] iOS temp file leakFile.Delete(tempPath) runs only on the success path inside try; an exception orphans the temp copy. Move cleanup to finally, and broaden the IOException-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.0 target 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

  1. Publish-before-flush ordering. using var outputStream here is a using-declaration scoped to the enclosing if (insertUri is not null) block, so the stream isn't flushed/disposed until the block closes (line 479) — after contentResolver.Update(insertUri, ...) clears IsPending=0 at 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 explicit using (var outputStream = ...) { ... } block that closes before clearing IsPending.

  2. Orphaned pending row on failure. If CopyToAsync (line 464) throws (disk full, I/O error), control jumps to the catch at line 481 which only logs; the row inserted with IsPending=1 (line 449) is never set to 0 nor deleted, leaving a permanently-pending entry that stays invisible in the gallery. Delete insertUri on the failure path — the existing else branch already does this when OpenOutputStream returns null, and the copy-throws case needs the same cleanup.

}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Failed to save to gallery: {ex.Message}");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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) { }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-essentials-mediapicker s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS MediaPicker CapturePhotoAsync without "PhotosAddOnly" permission

6 participants