diff --git a/src/Essentials/samples/Samples/View/MediaPickerPage.xaml b/src/Essentials/samples/Samples/View/MediaPickerPage.xaml
index d0ccd4b3248c..00229c2188a6 100644
--- a/src/Essentials/samples/Samples/View/MediaPickerPage.xaml
+++ b/src/Essentials/samples/Samples/View/MediaPickerPage.xaml
@@ -111,6 +111,17 @@
IsToggled="{Binding PickerPreserveMetaData, Mode=TwoWay}"
OnColor="{AppThemeBinding Light=#007BFF, Dark=#0D6EFD}" />
+
+
+
+
+
+
diff --git a/src/Essentials/samples/Samples/ViewModel/MediaPickerViewModel.cs b/src/Essentials/samples/Samples/ViewModel/MediaPickerViewModel.cs
index d1e69d722dc6..9a972ba0e8ae 100644
--- a/src/Essentials/samples/Samples/ViewModel/MediaPickerViewModel.cs
+++ b/src/Essentials/samples/Samples/ViewModel/MediaPickerViewModel.cs
@@ -30,6 +30,7 @@ public class MediaPickerViewModel : BaseViewModel
int pickerMaximumHeight = 0;
bool pickerRotateImage = false;
bool pickerPreserveMetaData = true;
+ bool pickerSaveToGallery = false;
long imageByteLength = 0;
string imageDimensions = "";
private ObservableCollection photoList = [];
@@ -88,6 +89,12 @@ public bool PickerPreserveMetaData
set => SetProperty(ref pickerPreserveMetaData, value);
}
+ public bool PickerSaveToGallery
+ {
+ get => pickerSaveToGallery;
+ set => SetProperty(ref pickerSaveToGallery, value);
+ }
+
public long ImageByteLength
{
get => imageByteLength;
@@ -160,7 +167,8 @@ async void DoCapturePhoto()
MaximumWidth = PickerMaximumWidth > 0 ? PickerMaximumWidth : null,
MaximumHeight = PickerMaximumHeight > 0 ? PickerMaximumHeight : null,
RotateImage = PickerRotateImage,
- PreserveMetaData = PickerPreserveMetaData
+ PreserveMetaData = PickerPreserveMetaData,
+ SaveToGallery = PickerSaveToGallery
});
await LoadPhotoAsync(photo);
@@ -204,7 +212,10 @@ async void DoCaptureVideo()
{
try
{
- var video = await MediaPicker.CaptureVideoAsync();
+ var video = await MediaPicker.CaptureVideoAsync(new MediaPickerOptions
+ {
+ SaveToGallery = PickerSaveToGallery
+ });
ShowPhoto = false;
ShowMultiplePhotos = false;
diff --git a/src/Essentials/src/MediaPicker/MediaPicker.android.cs b/src/Essentials/src/MediaPicker/MediaPicker.android.cs
index 5632c51ad6f3..129277ed0d72 100644
--- a/src/Essentials/src/MediaPicker/MediaPicker.android.cs
+++ b/src/Essentials/src/MediaPicker/MediaPicker.android.cs
@@ -64,8 +64,8 @@ public async Task CaptureAsync(MediaPickerOptions options, bool phot
}
await Permissions.EnsureGrantedAsync();
- // StorageWrite no longer exists starting from Android API 33
- if (!OperatingSystem.IsAndroidVersionAtLeast(33))
+ // Only request storage write permission when saving to gallery on older Android versions
+ if (options?.SaveToGallery == true && !OperatingSystem.IsAndroidVersionAtLeast(29))
await Permissions.EnsureGrantedAsync();
var captureIntent = new Intent(photo ? MediaStore.ActionImageCapture : MediaStore.ActionVideoCapture);
@@ -120,6 +120,12 @@ public async Task CaptureAsync(MediaPickerOptions options, bool phot
captureResult = await CaptureVideoAsync(captureIntent);
}
+ // Save to gallery if requested
+ if (captureResult is not null && options?.SaveToGallery == true)
+ {
+ await SaveToGalleryAsync(captureResult, photo);
+ }
+
// Return the file that we just captured
return captureResult is not null ? new FileResult(captureResult) : null;
}
@@ -415,6 +421,86 @@ void OnResult(Intent intent)
return path;
}
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Failed to save to gallery: {ex.Message}");
+ }
+ }
+
+ static string GetMimeType(string extension, bool isPhoto)
+ {
+ return extension switch
+ {
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".png" => "image/png",
+ ".heic" or ".heif" => "image/heif",
+ ".webp" => "image/webp",
+ ".gif" => "image/gif",
+ ".mp4" => "video/mp4",
+ ".3gp" => "video/3gpp",
+ ".mkv" => "video/x-matroska",
+ ".webm" => "video/webm",
+ _ => isPhoto ? "image/jpeg" : "video/mp4",
+ };
+ }
+
async Task> PickMultipleUsingIntermediateActivity(MediaPickerOptions options, bool photo)
{
var intent = new Intent(Intent.ActionGetContent);
diff --git a/src/Essentials/src/MediaPicker/MediaPicker.ios.cs b/src/Essentials/src/MediaPicker/MediaPicker.ios.cs
index 5e7ca0389f65..535bd9eac5da 100644
--- a/src/Essentials/src/MediaPicker/MediaPicker.ios.cs
+++ b/src/Essentials/src/MediaPicker/MediaPicker.ios.cs
@@ -98,7 +98,7 @@ public async Task PhotoAsync(MediaPickerOptions options, bool photo,
}
else
{
- if (!pickExisting)
+ if (!pickExisting && options?.SaveToGallery == true)
{
await Permissions.EnsureGrantedAsync();
}
@@ -169,6 +169,12 @@ public async Task PhotoAsync(MediaPickerOptions options, bool photo,
PickerRef?.Dispose();
PickerRef = null;
+ // Save captured media to the photo gallery if requested
+ if (!pickExisting && result is not null && options?.SaveToGallery == true)
+ {
+ await SaveToPhotoLibraryAsync(result);
+ }
+
return result;
}
@@ -481,6 +487,44 @@ static async Task RotateImageFile(FileResult original)
}
}
+ ///
+ /// Saves the captured media file to the device's photo library using PHPhotoLibrary.
+ ///
+ static async Task SaveToPhotoLibraryAsync(FileResult fileResult)
+ {
+ try
+ {
+ 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);
+ }
+
+ var url = NSUrl.FromFilename(tempPath);
+
+ await PHPhotoLibrary.SharedPhotoLibrary.PerformChangesAsync(() =>
+ {
+ if (IsImageFile(fileResult.FileName))
+ {
+ PHAssetChangeRequest.FromImage(url);
+ }
+ else
+ {
+ PHAssetChangeRequest.FromVideo(url);
+ }
+ });
+
+ try { File.Delete(tempPath); } catch (IOException) { }
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Failed to save to photo library: {ex.Message}");
+ }
+ }
+
class PhotoPickerDelegate : UIImagePickerControllerDelegate
{
public Action CompletedHandler { get; set; }
diff --git a/src/Essentials/src/MediaPicker/MediaPicker.shared.cs b/src/Essentials/src/MediaPicker/MediaPicker.shared.cs
index 279184fd5037..eec55b804621 100644
--- a/src/Essentials/src/MediaPicker/MediaPicker.shared.cs
+++ b/src/Essentials/src/MediaPicker/MediaPicker.shared.cs
@@ -229,5 +229,27 @@ public int CompressionQuality
/// Currently not supported on Windows.
///
public bool PreserveMetaData { get; set; } = true;
+
+ ///
+ /// Gets or sets whether the captured image or video should be saved to the device's photo gallery/library.
+ /// Default value is .
+ ///
+ ///
+ /// This property only applies to capture operations ( and
+ /// ). It has no effect on pick operations.
+ /// When set to , the necessary platform permissions will be requested automatically:
+ ///
+ /// - iOS: The NSPhotoLibraryAddUsageDescription key must be present in Info.plist.
+ /// The PhotosAddOnly permission will be requested.
+ /// - Android: On API < 29, WRITE_EXTERNAL_STORAGE permission is required.
+ /// On API 29+, MediaStore is used and no additional permission is needed.
+ /// - macOS (Mac Catalyst): The NSPhotoLibraryAddUsageDescription key must be present in Info.plist.
+ /// The PhotosAddOnly permission will be requested.
+ /// - Windows: Not supported. This property is ignored on Windows. The captured media is saved to a
+ /// temporary cache folder and the user can save it manually.
+ /// - Tizen: Not supported. This property is ignored on Tizen.
+ ///
+ ///
+ public bool SaveToGallery { get; set; } = false;
}
}
diff --git a/src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
index 3b89b0ef304a..e782ed8ecf07 100644
--- a/src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
+++ b/src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
@@ -9,3 +9,5 @@ override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.CanConvertTo(Syste
override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object! value) -> object?
override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.ConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object? value, System.Type! destinationType) -> object?
static Microsoft.Maui.Storage.FilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task?>!
+Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.get -> bool
+Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.set -> void
diff --git a/src/Essentials/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
index 86dcde44a4d1..d717bcf2a24e 100644
--- a/src/Essentials/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
+++ b/src/Essentials/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
@@ -4,6 +4,8 @@
Microsoft.Maui.Devices.Sensors.LocationTypeConverter
Microsoft.Maui.Devices.Sensors.LocationTypeConverter.LocationTypeConverter() -> void
Microsoft.Maui.Storage.IFilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task?>!
+Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.get -> bool
+Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.set -> void
override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.CanConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Type! sourceType) -> bool
override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.CanConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Type? destinationType) -> bool
override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object! value) -> object?
diff --git a/src/Essentials/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
index 86dcde44a4d1..d717bcf2a24e 100644
--- a/src/Essentials/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
+++ b/src/Essentials/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
@@ -4,6 +4,8 @@
Microsoft.Maui.Devices.Sensors.LocationTypeConverter
Microsoft.Maui.Devices.Sensors.LocationTypeConverter.LocationTypeConverter() -> void
Microsoft.Maui.Storage.IFilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task?>!
+Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.get -> bool
+Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.set -> void
override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.CanConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Type! sourceType) -> bool
override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.CanConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Type? destinationType) -> bool
override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object! value) -> object?
diff --git a/src/Essentials/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
index fa1140fc2331..52e738c4e4ef 100644
--- a/src/Essentials/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
+++ b/src/Essentials/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
@@ -4,6 +4,8 @@
Microsoft.Maui.Devices.Sensors.LocationTypeConverter
Microsoft.Maui.Devices.Sensors.LocationTypeConverter.LocationTypeConverter() -> void
Microsoft.Maui.Storage.IFilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task?>!
+Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.get -> bool
+Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.set -> void
override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.CanConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Type? destinationType) -> bool
static Microsoft.Maui.Storage.FilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task?>!
~override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.CanConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Type sourceType) -> bool
diff --git a/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
index 3b89b0ef304a..e782ed8ecf07 100644
--- a/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
+++ b/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
@@ -9,3 +9,5 @@ override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.CanConvertTo(Syste
override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object! value) -> object?
override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.ConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object? value, System.Type! destinationType) -> object?
static Microsoft.Maui.Storage.FilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task?>!
+Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.get -> bool
+Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.set -> void
diff --git a/src/Essentials/src/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net/PublicAPI.Unshipped.txt
index 3b89b0ef304a..e782ed8ecf07 100644
--- a/src/Essentials/src/PublicAPI/net/PublicAPI.Unshipped.txt
+++ b/src/Essentials/src/PublicAPI/net/PublicAPI.Unshipped.txt
@@ -9,3 +9,5 @@ override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.CanConvertTo(Syste
override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object! value) -> object?
override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.ConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object? value, System.Type! destinationType) -> object?
static Microsoft.Maui.Storage.FilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task?>!
+Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.get -> bool
+Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.set -> void
diff --git a/src/Essentials/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
index 3b89b0ef304a..e782ed8ecf07 100644
--- a/src/Essentials/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
+++ b/src/Essentials/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
@@ -9,3 +9,5 @@ override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.CanConvertTo(Syste
override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object! value) -> object?
override Microsoft.Maui.Devices.Sensors.LocationTypeConverter.ConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object? value, System.Type! destinationType) -> object?
static Microsoft.Maui.Storage.FilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task?>!
+Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.get -> bool
+Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.set -> void