4 changed files with 406 additions and 2 deletions
@ -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(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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<StorageItemProperties> 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<IStorageFolder?> GetParentAsync() |
||||
|
{ |
||||
|
return Task.FromResult<IStorageFolder?>(new IOSStorageFolder(Url.RemoveLastPathComponent())); |
||||
|
} |
||||
|
|
||||
|
public Task ReleaseBookmark() |
||||
|
{ |
||||
|
// no-op
|
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
|
||||
|
public Task<string?> SaveBookmark() |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
if (!Url.StartAccessingSecurityScopedResource()) |
||||
|
{ |
||||
|
return Task.FromResult<string?>(null); |
||||
|
} |
||||
|
|
||||
|
var newBookmark = Url.CreateBookmarkData(NSUrlBookmarkCreationOptions.SuitableForBookmarkFile, Array.Empty<string>(), 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<string?>(null); |
||||
|
} |
||||
|
|
||||
|
return Task.FromResult<string?>( |
||||
|
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<Stream> OpenRead() |
||||
|
{ |
||||
|
return Task.FromResult<Stream>(new IOSSecurityScopedStream(Url, FileAccess.Read)); |
||||
|
} |
||||
|
|
||||
|
public Task<Stream> OpenWrite() |
||||
|
{ |
||||
|
return Task.FromResult<Stream>(new IOSSecurityScopedStream(Url, FileAccess.Write)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
internal sealed class IOSStorageFolder : IOSStorageItem, IStorageBookmarkFolder |
||||
|
{ |
||||
|
public IOSStorageFolder(NSUrl url) : base(url) |
||||
|
{ |
||||
|
} |
||||
|
} |
||||
@ -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<IReadOnlyList<IStorageFile>> 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<UTType>(); |
||||
|
#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<string>()) |
||||
|
.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<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark) |
||||
|
{ |
||||
|
return Task.FromResult<IStorageBookmarkFile?>(GetBookmarkedUrl(bookmark) is { } url |
||||
|
? new IOSStorageFile(url) : null); |
||||
|
} |
||||
|
|
||||
|
public Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark) |
||||
|
{ |
||||
|
return Task.FromResult<IStorageBookmarkFolder?>(GetBookmarkedUrl(bookmark) is { } url |
||||
|
? new IOSStorageFolder(url) : null); |
||||
|
} |
||||
|
|
||||
|
public Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options) |
||||
|
{ |
||||
|
return Task.FromException<IStorageFile?>( |
||||
|
new PlatformNotSupportedException("Save file picker is not supported by iOS")); |
||||
|
} |
||||
|
|
||||
|
public async Task<IReadOnlyList<IStorageFolder>> 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<NSUrl[]> ShowPicker(UIDocumentPickerViewController documentPicker) |
||||
|
{ |
||||
|
var tcs = new TaskCompletionSource<NSUrl[]>(); |
||||
|
documentPicker.Delegate = new PickerDelegate(urls => tcs.TrySetResult(urls)); |
||||
|
|
||||
|
if (documentPicker.PresentationController != null) |
||||
|
{ |
||||
|
documentPicker.PresentationController.Delegate = |
||||
|
new UIPresentationControllerDelegate(() => tcs.TrySetResult(Array.Empty<NSUrl>())); |
||||
|
} |
||||
|
|
||||
|
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<NSUrl[]>? _pickHandler; |
||||
|
|
||||
|
internal PickerDelegate(Action<NSUrl[]> pickHandler) |
||||
|
=> _pickHandler = pickHandler; |
||||
|
|
||||
|
public override void WasCancelled(UIDocumentPickerViewController controller) |
||||
|
=> _pickHandler?.Invoke(Array.Empty<NSUrl>()); |
||||
|
|
||||
|
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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue