From b6ee7b90d0a98caf5b008f0a4d7f4177ef5c6ece Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 26 Feb 2024 16:08:02 -0800 Subject: [PATCH] Some iOS/Android file picker fixes (#14677) * Use NSSearchPathDomain.User when opening a known folder on iOS * Use UIImagePickerController for WellKnownFolder.Pictures * Improve how Android handles read permissions --- .../Storage/AndroidStorageProvider.cs | 40 ++-- .../Avalonia.iOS/Storage/IOSStorageItem.cs | 7 + .../Storage/IOSStorageProvider.cs | 175 +++++++++++++----- 3 files changed, 160 insertions(+), 62 deletions(-) diff --git a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs index e35bde0acd..b249020784 100644 --- a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs +++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs @@ -55,12 +55,8 @@ internal class AndroidStorageProvider : IStorageProvider return null; } - var hasPerms = await _activity.CheckPermission(Manifest.Permission.ReadExternalStorage); - if (!hasPerms) - { - throw new SecurityException("Application doesn't have ReadExternalStorage permission. Make sure android manifest has this permission defined and user allowed it."); - } - + await EnsureUriReadPermission(androidUri); + var javaFile = new JavaFile(androidUriPath); if (javaFile.Exists() && javaFile.IsFile) { @@ -88,11 +84,7 @@ internal class AndroidStorageProvider : IStorageProvider return null; } - var hasPerms = await _activity.CheckPermission(Manifest.Permission.ReadExternalStorage); - if (!hasPerms) - { - throw new SecurityException("Application doesn't have ReadExternalStorage permission. Make sure android manifest has this permission defined and user allowed it."); - } + await EnsureUriReadPermission(androidUri); var javaFile = new JavaFile(androidUriPath); if (javaFile.Exists() && javaFile.IsDirectory) @@ -283,4 +275,30 @@ internal class AndroidStorageProvider : IStorageProvider return null; } + + private async Task EnsureUriReadPermission(AndroidUri androidUri) + { + bool hasPerms = false; + Exception? innerEx = null; + try + { + hasPerms = _activity.CheckUriPermission(androidUri, + global::Android.OS.Process.MyPid(), + global::Android.OS.Process.MyUid(), + ActivityFlags.GrantReadUriPermission) + == global::Android.Content.PM.Permission.Granted; + + // TODO: call RequestPermission or add proper permissions API, something like in Browser File API. + hasPerms = hasPerms || await _activity.CheckPermission(Manifest.Permission.ReadExternalStorage); + } + catch (Exception ex) + { + innerEx = ex; + } + + if (!hasPerms) + { + throw new InvalidOperationException("Application doesn't have READ_EXTERNAL_STORAGE permission. Make sure android manifest has this permission defined and user allowed it.", innerEx); + } + } } diff --git a/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs b/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs index 0dc117b7d6..f9a7aaf445 100644 --- a/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs +++ b/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs @@ -179,6 +179,13 @@ internal sealed class IOSStorageFolder : IOSStorageItem, IStorageBookmarkFolder { } + public IOSStorageFolder(NSUrl url, WellKnownFolder wellKnownFolder) : base(url, null) + { + WellKnownFolder = wellKnownFolder; + } + + public WellKnownFolder? WellKnownFolder { get; } + public async IAsyncEnumerable GetItemsAsync() { try diff --git a/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs b/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs index 915cc32980..4e812b5d98 100644 --- a/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs +++ b/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs @@ -1,9 +1,7 @@ using System; using System.Linq; using System.Collections.Generic; -using System.Diagnostics; using System.Threading.Tasks; -using Avalonia.Controls; using Avalonia.Logging; using Avalonia.Platform.Storage; using UIKit; @@ -11,6 +9,7 @@ using Foundation; using UniformTypeIdentifiers; using UTTypeLegacy = MobileCoreServices.UTType; using UTType = UniformTypeIdentifiers.UTType; +using System.Runtime.Versioning; namespace Avalonia.iOS.Storage; @@ -28,51 +27,48 @@ internal class IOSStorageProvider : IStorageProvider public bool CanPickFolder => true; - public async Task> OpenFilePickerAsync(FilePickerOpenOptions options) + public Task> OpenFilePickerAsync(FilePickerOpenOptions options) + { + if (!options.AllowMultiple + && options.SuggestedStartLocation is IOSStorageFolder { WellKnownFolder: WellKnownFolder.Pictures }) + { + return OpenImagePickerAsync(options); + } + else + { + return OpenDocumentsPickerAsync(options); + } + } + + private async Task> OpenImagePickerAsync(FilePickerOpenOptions options) + { +#pragma warning disable CA1422 // Validate platform compatibility - we can't use PHImagePicker here. + using var imagePicker = new UIImagePickerController(); + imagePicker.SourceType = UIImagePickerControllerSourceType.PhotoLibrary; + imagePicker.MediaTypes = new string[] { UTTypeLegacy.Image }; + imagePicker.AllowsEditing = false; + imagePicker.Title = options.Title; +#pragma warning restore CA1422 // Validate platform compatibility + + var tcs = new TaskCompletionSource(); + imagePicker.Delegate = new ImageOpenPickerDelegate(urls => tcs.TrySetResult(urls)); + var urls = await ShowPicker(imagePicker, tcs); + + return urls.Select(u => new IOSStorageFile(u)).ToArray(); + } + + private async Task> OpenDocumentsPickerAsync(FilePickerOpenOptions options) { UIDocumentPickerViewController documentPicker; if (OperatingSystem.IsIOSVersionAtLeast(14)) { - var allowedUtils = options.FileTypeFilter?.SelectMany(f => - { - // We check for OS version outside of the lambda, it's safe. -#pragma warning disable CA1416 - if (f.TryGetExtensions() is { } extensions && extensions.Any()) - { - return extensions.Select(UTType.CreateFromExtension); - } - if (f.AppleUniformTypeIdentifiers?.Any() == true) - { - return f.AppleUniformTypeIdentifiers.Select(UTType.CreateFromIdentifier); - } - if (f.MimeTypes?.Any() == true) - { - return f.MimeTypes.Select(UTType.CreateFromMimeType); - } - return Array.Empty(); -#pragma warning restore CA1416 - }) - .Where(id => id is not null) - .ToArray() ?? new[] - { - UTTypes.Content, - UTTypes.Item, - UTTypes.Data - }; - documentPicker = new UIDocumentPickerViewController(allowedUtils!, false); + var allowedTypes = FileTypesToUTType(options.FileTypeFilter); + documentPicker = new UIDocumentPickerViewController(allowedTypes!, false); } else { - var allowedUtils = options.FileTypeFilter?.SelectMany(f => f.AppleUniformTypeIdentifiers ?? Array.Empty()) - .ToArray() ?? new[] - { -#pragma warning disable CA1422 - UTTypeLegacy.Content, - UTTypeLegacy.Item, -#pragma warning restore CA1422 - "public.data" - }; - documentPicker = new UIDocumentPickerViewController(allowedUtils, UIDocumentPickerMode.Open); + var allowedTypes = FileTypesToUTTypeLegacy(options.FileTypeFilter); + documentPicker = new UIDocumentPickerViewController(allowedTypes, UIDocumentPickerMode.Open); } using (documentPicker) @@ -87,7 +83,9 @@ internal class IOSStorageProvider : IStorageProvider documentPicker.AllowsMultipleSelection = options.AllowMultiple; } - var urls = await ShowPicker(documentPicker); + documentPicker.Title = options.Title; + + var urls = await ShowDocumentPicker(documentPicker); return urls.Select(u => new IOSStorageFile(u)).ToArray(); } } @@ -128,14 +126,19 @@ internal class IOSStorageProvider : IStorageProvider WellKnownFolder.Videos => NSSearchPathDirectory.MoviesDirectory, _ => throw new ArgumentOutOfRangeException(nameof(wellKnownFolder), wellKnownFolder, null) }; - - var uri = NSFileManager.DefaultManager.GetUrl(directoryType, NSSearchPathDomain.Local, null, true, out var error); + + var uri = NSFileManager.DefaultManager.GetUrl(directoryType, NSSearchPathDomain.User, null, true, out var error); if (error != null) { throw new NSErrorException(error); } - return Task.FromResult(new IOSStorageFolder(uri)); + if (uri is null) + { + return Task.FromResult(null); + } + + return Task.FromResult(new IOSStorageFolder(uri, wellKnownFolder)); } public Task SaveFilePickerAsync(FilePickerSaveOptions options) @@ -162,7 +165,7 @@ internal class IOSStorageProvider : IStorageProvider documentPicker.AllowsMultipleSelection = options.AllowMultiple; } - var urls = await ShowPicker(documentPicker); + var urls = await ShowDocumentPicker(documentPicker); return urls.Select(u => new IOSStorageFolder(u)).ToArray(); } @@ -176,20 +179,24 @@ internal class IOSStorageProvider : IStorageProvider }; } - private Task ShowPicker(UIDocumentPickerViewController documentPicker) + private Task ShowDocumentPicker(UIDocumentPickerViewController documentPicker) { var tcs = new TaskCompletionSource(); documentPicker.Delegate = new PickerDelegate(urls => tcs.TrySetResult(urls)); + return ShowPicker(documentPicker, tcs); + } - if (documentPicker.PresentationController != null) + private Task ShowPicker(UIViewController picker, TaskCompletionSource tcs) + { + if (picker.PresentationController != null) { - documentPicker.PresentationController.Delegate = + picker.PresentationController.Delegate = new UIPresentationControllerDelegate(() => tcs.TrySetResult(Array.Empty())); } var controller = _view.Window?.RootViewController ?? throw new InvalidOperationException("RootViewController wasn't initialized"); - controller.PresentViewController(documentPicker, true, null); - + controller.PresentViewController(picker, true, null); + return tcs.Task; } @@ -208,7 +215,73 @@ internal class IOSStorageProvider : IStorageProvider } return url; } - + + private static string[] FileTypesToUTTypeLegacy(IReadOnlyList? filePickerFileTypes) + { + return filePickerFileTypes? + .SelectMany(f => f.AppleUniformTypeIdentifiers ?? Array.Empty()) + .ToArray() ?? new[] + { +#pragma warning disable CA1422 + UTTypeLegacy.Content, + UTTypeLegacy.Item, +#pragma warning restore CA1422 + "public.data" + }; + } + + [SupportedOSPlatform("ios14.0")] + private static UTType[] FileTypesToUTType(IReadOnlyList? filePickerFileTypes) + { + return filePickerFileTypes?.SelectMany(f => + { + if (f.TryGetExtensions() is { } extensions && extensions.Any()) + { + return extensions.Select(UTType.CreateFromExtension); + } + if (f.AppleUniformTypeIdentifiers?.Any() == true) + { + return f.AppleUniformTypeIdentifiers.Select(UTType.CreateFromIdentifier); + } + if (f.MimeTypes?.Any() == true) + { + return f.MimeTypes.Select(UTType.CreateFromMimeType); + } + return Array.Empty(); + }) + .Where(id => id is not null) + .Cast() + .ToArray() ?? new[] + { + UTTypes.Content, + UTTypes.Item, + UTTypes.Data + }; + } + + private class ImageOpenPickerDelegate : UIImagePickerControllerDelegate + { + private readonly Action? _pickHandler; + + internal ImageOpenPickerDelegate(Action pickHandler) + => _pickHandler = pickHandler; + + public override void Canceled(UIImagePickerController picker) + => _pickHandler?.Invoke(Array.Empty()); + + public override void FinishedPickingMedia(UIImagePickerController picker, NSDictionary info) + { + if (info.ValueForKey(new NSString("UIImagePickerControllerImageURL")) is NSUrl nSUrl) + { + _pickHandler?.Invoke(new[] { nSUrl }); + } + else + { + _pickHandler?.Invoke(Array.Empty()); + } + } + } + private class PickerDelegate : UIDocumentPickerDelegate { private readonly Action? _pickHandler;