diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.cs b/src/iOS/Avalonia.iOS/AvaloniaView.cs index 0a47b152edd..cc404016384 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.cs @@ -6,7 +6,9 @@ using Avalonia.Input; using Avalonia.Input.Raw; using Avalonia.Input.TextInput; +using Avalonia.iOS.Storage; using Avalonia.Platform; +using Avalonia.Platform.Storage; using Avalonia.Rendering; using CoreAnimation; using Foundation; @@ -41,9 +43,10 @@ public AvaloniaView() ); _topLevelImpl.Surfaces = new[] {new EaglLayerSurface(l)}; MultipleTouchEnabled = true; + AddSubviews(new UIView[] { new UIKit.UIButton(UIButtonType.InfoDark) }); } - internal class TopLevelImpl : ITopLevelImplWithTextInputMethod, ITopLevelImplWithNativeControlHost + internal class TopLevelImpl : ITopLevelImplWithTextInputMethod, ITopLevelImplWithNativeControlHost, ITopLevelImplWithStorageProvider { private readonly AvaloniaView _view; public AvaloniaView View => _view; @@ -52,6 +55,7 @@ public TopLevelImpl(AvaloniaView view) { _view = view; NativeControlHost = new NativeControlHostImpl(_view); + StorageProvider = new IOSStorageProvider(view); } public void Dispose() @@ -113,7 +117,8 @@ public void SetTransparencyLevelHint(WindowTransparencyLevel transparencyLevel) new AcrylicPlatformCompensationLevels(); public ITextInputMethodImpl? TextInputMethod => _view; - public INativeControlHostImpl NativeControlHost { get; } + public INativeControlHostImpl NativeControlHost { get; } + public IStorageProvider StorageProvider { get; } } [Export("layerClass")] diff --git a/src/iOS/Avalonia.iOS/Storage/IOSSecurityScopedStream.cs b/src/iOS/Avalonia.iOS/Storage/IOSSecurityScopedStream.cs new file mode 100644 index 00000000000..784937cb107 --- /dev/null +++ b/src/iOS/Avalonia.iOS/Storage/IOSSecurityScopedStream.cs @@ -0,0 +1,66 @@ +using System.IO; + +using Foundation; + +using UIKit; + +#nullable enable + +namespace Avalonia.iOS.Storage; + +internal sealed class IOSSecurityScopedStream : Stream +{ + private readonly UIDocument _document; + private readonly FileStream _stream; + private readonly NSUrl _url; + + internal IOSSecurityScopedStream(NSUrl url, FileAccess access) + { + _document = new UIDocument(url); + var path = _document.FileUrl.Path; + _url = url; + _url.StartAccessingSecurityScopedResource(); + _stream = File.Open(path, FileMode.Open, access); + } + + public override bool CanRead => _stream.CanRead; + + public override bool CanSeek => _stream.CanSeek; + + public override bool CanWrite => _stream.CanWrite; + + public override long Length => _stream.Length; + + public override long Position + { + get => _stream.Position; + set => _stream.Position = value; + } + + public override void Flush() => + _stream.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => + _stream.Read(buffer, offset, count); + + public override long Seek(long offset, SeekOrigin origin) => + _stream.Seek(offset, origin); + + public override void SetLength(long value) => + _stream.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => + _stream.Write(buffer, offset, count); + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + { + _stream.Dispose(); + _document.Dispose(); + _url.StopAccessingSecurityScopedResource(); + } + } +} diff --git a/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs b/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs new file mode 100644 index 00000000000..6fb296d0e0f --- /dev/null +++ b/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs @@ -0,0 +1,121 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Logging; +using Avalonia.Platform.Storage; +using Foundation; + +using UIKit; + +#nullable enable + +namespace Avalonia.iOS.Storage; + +internal abstract class IOSStorageItem : IStorageBookmarkItem +{ + private readonly string _filePath; + + protected IOSStorageItem(NSUrl url) + { + Url = url ?? throw new ArgumentNullException(nameof(url)); + + using (var doc = new UIDocument(url)) + { + _filePath = doc.FileUrl?.Path ?? url.FilePathUrl.Path; + Name = doc.LocalizedName ?? Path.GetFileName(_filePath) ?? url.FilePathUrl.LastPathComponent; + } + } + + internal NSUrl Url { get; } + + public bool CanBookmark => true; + + public string Name { get; } + + public Task GetBasicPropertiesAsync() + { + var attributes = NSFileManager.DefaultManager.GetAttributes(_filePath, out var error); + if (error is not null) + { + Logger.TryGet(LogEventLevel.Error, LogArea.IOSPlatform)?. + Log(this, "GetBasicPropertiesAsync returned an error: {ErrorCode} {ErrorMessage}", error.Code, error.LocalizedFailureReason); + } + return Task.FromResult(new StorageItemProperties(attributes?.Size, (DateTime)attributes?.CreationDate, (DateTime)attributes?.ModificationDate)); + } + + public Task GetParentAsync() + { + return Task.FromResult(new IOSStorageFolder(Url.RemoveLastPathComponent())); + } + + public Task ReleaseBookmark() + { + // no-op + return Task.CompletedTask; + } + + public Task SaveBookmark() + { + try + { + if (!Url.StartAccessingSecurityScopedResource()) + { + return Task.FromResult(null); + } + + var newBookmark = Url.CreateBookmarkData(NSUrlBookmarkCreationOptions.SuitableForBookmarkFile, Array.Empty(), null, out var bookmarkError); + if (bookmarkError is not null) + { + Logger.TryGet(LogEventLevel.Error, LogArea.IOSPlatform)?. + Log(this, "SaveBookmark returned an error: {ErrorCode} {ErrorMessage}", bookmarkError.Code, bookmarkError.LocalizedFailureReason); + return Task.FromResult(null); + } + + return Task.FromResult( + newBookmark.GetBase64EncodedString(NSDataBase64EncodingOptions.None)); + } + finally + { + Url.StopAccessingSecurityScopedResource(); + } + } + + public bool TryGetUri([NotNullWhen(true)] out Uri uri) + { + uri = Url; + return uri is not null; + } + + public void Dispose() + { + } +} + +internal sealed class IOSStorageFile : IOSStorageItem, IStorageBookmarkFile +{ + public IOSStorageFile(NSUrl url) : base(url) + { + } + + public bool CanOpenRead => true; + + public bool CanOpenWrite => true; + + public Task OpenRead() + { + return Task.FromResult(new IOSSecurityScopedStream(Url, FileAccess.Read)); + } + + public Task OpenWrite() + { + return Task.FromResult(new IOSSecurityScopedStream(Url, FileAccess.Write)); + } +} + +internal sealed class IOSStorageFolder : IOSStorageItem, IStorageBookmarkFolder +{ + public IOSStorageFolder(NSUrl url) : base(url) + { + } +} diff --git a/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs b/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs new file mode 100644 index 00000000000..834418437b2 --- /dev/null +++ b/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs @@ -0,0 +1,212 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Avalonia.Logging; +using Avalonia.Platform.Storage; +using UIKit; +using Foundation; +using UniformTypeIdentifiers; +using UTTypeLegacy = MobileCoreServices.UTType; +using UTType = UniformTypeIdentifiers.UTType; + +#nullable enable + +namespace Avalonia.iOS.Storage; + +internal class IOSStorageProvider : IStorageProvider +{ + private readonly AvaloniaView _view; + public IOSStorageProvider(AvaloniaView view) + { + _view = view; + } + + public bool CanOpen => true; + + public bool CanSave => false; + + public bool CanPickFolder => true; + + public async Task> OpenFilePickerAsync(FilePickerOpenOptions options) + { + UIDocumentPickerViewController documentPicker; + if (OperatingSystem.IsIOSVersionAtLeast(14)) + { + var allowedUtis = options.FileTypeFilter?.SelectMany(f => + { + // We check for OS version outside of the lambda, it's safe. +#pragma warning disable CA1416 + if (f.AppleUniformTypeIdentifiers?.Any() == true) + { + return f.AppleUniformTypeIdentifiers.Select(id => UTType.CreateFromIdentifier(id)); + } + if (f.MimeTypes?.Any() == true) + { + return f.MimeTypes.Select(id => UTType.CreateFromMimeType(id)); + } + return Array.Empty(); +#pragma warning restore CA1416 + }) + .Where(id => id is not null) + .ToArray() ?? new[] + { + UTTypes.Content, + UTTypes.Item, + UTTypes.Data + }; + documentPicker = new UIDocumentPickerViewController(allowedUtis!, false); + } + else + { + var allowedUtis = options.FileTypeFilter?.SelectMany(f => f.AppleUniformTypeIdentifiers ?? Array.Empty()) + .ToArray() ?? new[] + { + UTTypeLegacy.Content, + UTTypeLegacy.Item, + "public.data" + }; + documentPicker = new UIDocumentPickerViewController(allowedUtis, UIDocumentPickerMode.Open); + } + + using (documentPicker) + { + if (OperatingSystem.IsIOSVersionAtLeast(13)) + { + documentPicker.DirectoryUrl = GetUrlFromFolder(options.SuggestedStartLocation); + } + + if (OperatingSystem.IsIOSVersionAtLeast(11, 0)) + { + documentPicker.AllowsMultipleSelection = options.AllowMultiple; + } + + var urls = await ShowPicker(documentPicker); + return urls.Select(u => new IOSStorageFile(u)).ToArray(); + } + } + + public Task OpenFileBookmarkAsync(string bookmark) + { + return Task.FromResult(GetBookmarkedUrl(bookmark) is { } url + ? new IOSStorageFile(url) : null); + } + + public Task OpenFolderBookmarkAsync(string bookmark) + { + return Task.FromResult(GetBookmarkedUrl(bookmark) is { } url + ? new IOSStorageFolder(url) : null); + } + + public Task SaveFilePickerAsync(FilePickerSaveOptions options) + { + return Task.FromException( + new PlatformNotSupportedException("Save file picker is not supported by iOS")); + } + + public async Task> OpenFolderPickerAsync(FolderPickerOpenOptions options) + { + using var documentPicker = OperatingSystem.IsIOSVersionAtLeast(14) ? + new UIDocumentPickerViewController(new[] { UTTypes.Folder }, false) : + new UIDocumentPickerViewController(new string[] { UTTypeLegacy.Folder }, UIDocumentPickerMode.Open); + + if (OperatingSystem.IsIOSVersionAtLeast(13)) + { + documentPicker.DirectoryUrl = GetUrlFromFolder(options.SuggestedStartLocation); + } + + if (OperatingSystem.IsIOSVersionAtLeast(11)) + { + documentPicker.AllowsMultipleSelection = options.AllowMultiple; + } + + var urls = await ShowPicker(documentPicker); + return urls.Select(u => new IOSStorageFolder(u)).ToArray(); + } + + private static NSUrl? GetUrlFromFolder(IStorageFolder? folder) + { + if (folder is IOSStorageFolder iosFolder) + { + return iosFolder.Url; + } + + if (folder?.TryGetUri(out var fullPath) == true) + { + return fullPath; + } + + return null; + } + + private Task ShowPicker(UIDocumentPickerViewController documentPicker) + { + var tcs = new TaskCompletionSource(); + documentPicker.Delegate = new PickerDelegate(urls => tcs.TrySetResult(urls)); + + if (documentPicker.PresentationController != null) + { + documentPicker.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); + + return tcs.Task; + } + + private NSUrl? GetBookmarkedUrl(string bookmark) + { + var url = NSUrl.FromBookmarkData(new NSData(bookmark, NSDataBase64DecodingOptions.None), + NSUrlBookmarkResolutionOptions.WithoutUI, null, out var isStale, out var error); + if (isStale) + { + Logger.TryGet(LogEventLevel.Warning, LogArea.IOSPlatform)?.Log(this, "Stale bookmark detected"); + } + + if (error != null) + { + throw new NSErrorException(error); + } + return url; + } + + private class PickerDelegate : UIDocumentPickerDelegate + { + private readonly Action? _pickHandler; + + internal PickerDelegate(Action pickHandler) + => _pickHandler = pickHandler; + + public override void WasCancelled(UIDocumentPickerViewController controller) + => _pickHandler?.Invoke(Array.Empty()); + + public override void DidPickDocument(UIDocumentPickerViewController controller, NSUrl[] urls) + => _pickHandler?.Invoke(urls); + + public override void DidPickDocument(UIDocumentPickerViewController controller, NSUrl url) + => _pickHandler?.Invoke(new[] { url }); + } + + private class UIPresentationControllerDelegate : UIAdaptivePresentationControllerDelegate + { + private Action? _dismissHandler; + + internal UIPresentationControllerDelegate(Action dismissHandler) + => this._dismissHandler = dismissHandler; + + public override void DidDismiss(UIPresentationController presentationController) + { + _dismissHandler?.Invoke(); + _dismissHandler = null; + } + + protected override void Dispose(bool disposing) + { + _dismissHandler?.Invoke(); + base.Dispose(disposing); + } + } +}