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