committed by
GitHub
73 changed files with 3422 additions and 601 deletions
@ -1,29 +1,57 @@ |
|||||
<UserControl xmlns="https://github.com/avaloniaui" |
<UserControl x:Class="ControlCatalog.Pages.DialogsPage" |
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
xmlns="https://github.com/avaloniaui" |
||||
x:Class="ControlCatalog.Pages.DialogsPage"> |
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> |
||||
<StackPanel Orientation="Vertical" Spacing="4" Margin="4"> |
<StackPanel Margin="4" |
||||
<CheckBox Name="UseFilters">Use filters</CheckBox> |
Orientation="Vertical" |
||||
<Button Name="OpenFile">_Open File</Button> |
Spacing="4"> |
||||
<Button Name="OpenMultipleFiles">Open _Multiple File</Button> |
|
||||
<Button Name="SaveFile">_Save File</Button> |
|
||||
<Button Name="SelectFolder">Select Fo_lder</Button> |
|
||||
<Button Name="OpenBoth">Select _Both</Button> |
|
||||
|
|
||||
<TextBlock x:Name="PickerLastResultsVisible" |
<TextBlock Text="Windows:" /> |
||||
Classes="h2" |
|
||||
IsVisible="False" |
|
||||
Text="Last picker results:" /> |
|
||||
<ItemsPresenter x:Name="PickerLastResults" /> |
|
||||
|
|
||||
<TextBlock Margin="0, 8, 0, 0" |
<Expander Header="Window dialogs"> |
||||
Classes="h1" |
<StackPanel Spacing="4"> |
||||
Text="Window dialogs" /> |
<Button Name="DecoratedWindow">Decorated _window</Button> |
||||
<Button Name="DecoratedWindow">Decorated _window</Button> |
<Button Name="DecoratedWindowDialog">Decorated w_indow (dialog)</Button> |
||||
<Button Name="DecoratedWindowDialog">Decorated w_indow (dialog)</Button> |
<Button Name="Dialog" ToolTip.Tip="Shows a dialog">_Dialog</Button> |
||||
<Button Name="Dialog" ToolTip.Tip="Shows a dialog">_Dialog</Button> |
<Button Name="DialogNoTaskbar">Dialog (_No taskbar icon)</Button> |
||||
<Button Name="DialogNoTaskbar">Dialog (_No taskbar icon)</Button> |
<Button Name="OwnedWindow">Own_ed window</Button> |
||||
<Button Name="OwnedWindow">Own_ed window</Button> |
<Button Name="OwnedWindowNoTaskbar">Owned window (No tas_kbar icon)</Button> |
||||
<Button Name="OwnedWindowNoTaskbar">Owned window (No tas_kbar icon)</Button> |
</StackPanel> |
||||
|
</Expander> |
||||
|
|
||||
|
<TextBlock Margin="0,20,0,0" Text="Pickers:" /> |
||||
|
|
||||
|
<CheckBox Name="UseFilters">Use filters</CheckBox> |
||||
|
<Expander Header="FilePicker API"> |
||||
|
<StackPanel Spacing="4"> |
||||
|
<CheckBox Name="ForceManaged">Force managed dialog</CheckBox> |
||||
|
<CheckBox Name="OpenMultiple">Open multiple</CheckBox> |
||||
|
<Button Name="OpenFolderPicker">Select Fo_lder</Button> |
||||
|
<Button Name="OpenFilePicker">_Open File</Button> |
||||
|
<Button Name="SaveFilePicker">_Save File</Button> |
||||
|
<Button Name="OpenFileFromBookmark">Open File Bookmark</Button> |
||||
|
<Button Name="OpenFolderFromBookmark">Open Folder Bookmark</Button> |
||||
|
</StackPanel> |
||||
|
</Expander> |
||||
|
<Expander Header="Legacy OpenFileDialog"> |
||||
|
<StackPanel Spacing="4"> |
||||
|
<Button Name="OpenFile">_Open File</Button> |
||||
|
<Button Name="OpenMultipleFiles">Open _Multiple File</Button> |
||||
|
<Button Name="SaveFile">_Save File</Button> |
||||
|
<Button Name="SelectFolder">Select Fo_lder</Button> |
||||
|
<Button Name="OpenBoth">Select _Both</Button> |
||||
|
</StackPanel> |
||||
|
</Expander> |
||||
|
|
||||
|
<TextBlock x:Name="PickerLastResultsVisible" |
||||
|
Classes="h2" |
||||
|
IsVisible="False" |
||||
|
Text="Last picker results:" /> |
||||
|
<ItemsPresenter x:Name="PickerLastResults" /> |
||||
|
|
||||
|
<TextBox Name="BookmarkContainer" Watermark="Bookmark" /> |
||||
|
<TextBox Name="OpenedFileContent" |
||||
|
MaxLines="10" |
||||
|
Watermark="Picked file content" /> |
||||
|
|
||||
</StackPanel> |
</StackPanel> |
||||
</UserControl> |
</UserControl> |
||||
|
|||||
@ -0,0 +1,244 @@ |
|||||
|
#nullable enable |
||||
|
|
||||
|
using System; |
||||
|
using System.Diagnostics.CodeAnalysis; |
||||
|
using System.IO; |
||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
using Android.Content; |
||||
|
using Android.Provider; |
||||
|
using Avalonia.Logging; |
||||
|
using Avalonia.Platform.Storage; |
||||
|
using Java.Lang; |
||||
|
using AndroidUri = Android.Net.Uri; |
||||
|
using Exception = System.Exception; |
||||
|
using JavaFile = Java.IO.File; |
||||
|
|
||||
|
namespace Avalonia.Android.Platform.Storage; |
||||
|
|
||||
|
internal abstract class AndroidStorageItem : IStorageBookmarkItem |
||||
|
{ |
||||
|
private Context? _context; |
||||
|
|
||||
|
protected AndroidStorageItem(Context context, AndroidUri uri) |
||||
|
{ |
||||
|
_context = context; |
||||
|
Uri = uri; |
||||
|
} |
||||
|
|
||||
|
internal AndroidUri Uri { get; } |
||||
|
|
||||
|
protected Context Context => _context ?? throw new ObjectDisposedException(nameof(AndroidStorageItem)); |
||||
|
|
||||
|
public string Name => GetColumnValue(Context, Uri, MediaStore.IMediaColumns.DisplayName) |
||||
|
?? Uri.PathSegments?.LastOrDefault() ?? string.Empty; |
||||
|
|
||||
|
public bool CanBookmark => true; |
||||
|
|
||||
|
public Task<string?> SaveBookmark() |
||||
|
{ |
||||
|
Context.ContentResolver?.TakePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission); |
||||
|
return Task.FromResult(Uri.ToString()); |
||||
|
} |
||||
|
|
||||
|
public Task ReleaseBookmark() |
||||
|
{ |
||||
|
Context.ContentResolver?.ReleasePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission); |
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
|
||||
|
public bool TryGetUri([NotNullWhen(true)] out Uri? uri) |
||||
|
{ |
||||
|
uri = new Uri(Uri.ToString()!); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
public abstract Task<StorageItemProperties> GetBasicPropertiesAsync(); |
||||
|
|
||||
|
protected string? GetColumnValue(Context context, AndroidUri contentUri, string column, string? selection = null, string[]? selectionArgs = null) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var projection = new[] { column }; |
||||
|
using var cursor = context.ContentResolver!.Query(contentUri, projection, selection, selectionArgs, null); |
||||
|
if (cursor?.MoveToFirst() == true) |
||||
|
{ |
||||
|
var columnIndex = cursor.GetColumnIndex(column); |
||||
|
if (columnIndex != -1) |
||||
|
return cursor.GetString(columnIndex); |
||||
|
} |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "File metadata reader failed: '{Exception}'", ex); |
||||
|
} |
||||
|
|
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
public Task<IStorageFolder?> GetParentAsync() |
||||
|
{ |
||||
|
using var javaFile = new JavaFile(Uri.Path!); |
||||
|
|
||||
|
// Java file represents files AND directories. Don't be confused.
|
||||
|
if (javaFile.ParentFile is {} parentFile |
||||
|
&& AndroidUri.FromFile(parentFile) is {} androidUri) |
||||
|
{ |
||||
|
return Task.FromResult<IStorageFolder?>(new AndroidStorageFolder(Context, androidUri)); |
||||
|
} |
||||
|
|
||||
|
return Task.FromResult<IStorageFolder?>(null); |
||||
|
} |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
_context = null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
internal sealed class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder |
||||
|
{ |
||||
|
public AndroidStorageFolder(Context context, AndroidUri uri) : base(context, uri) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
public override Task<StorageItemProperties> GetBasicPropertiesAsync() |
||||
|
{ |
||||
|
return Task.FromResult(new StorageItemProperties()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkFile |
||||
|
{ |
||||
|
public AndroidStorageFile(Context context, AndroidUri uri) : base(context, uri) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
public bool CanOpenRead => true; |
||||
|
|
||||
|
public bool CanOpenWrite => true; |
||||
|
|
||||
|
public Task<Stream> OpenRead() => Task.FromResult(OpenContentStream(Context, Uri, false) |
||||
|
?? throw new InvalidOperationException("Failed to open content stream")); |
||||
|
|
||||
|
public Task<Stream> OpenWrite() => Task.FromResult(OpenContentStream(Context, Uri, true) |
||||
|
?? throw new InvalidOperationException("Failed to open content stream")); |
||||
|
|
||||
|
private Stream? OpenContentStream(Context context, AndroidUri uri, bool isOutput) |
||||
|
{ |
||||
|
var isVirtual = IsVirtualFile(context, uri); |
||||
|
if (isVirtual) |
||||
|
{ |
||||
|
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "Content URI was virtual: '{Uri}'", uri); |
||||
|
return GetVirtualFileStream(context, uri, isOutput); |
||||
|
} |
||||
|
|
||||
|
return isOutput |
||||
|
? context.ContentResolver?.OpenOutputStream(uri) |
||||
|
: context.ContentResolver?.OpenInputStream(uri); |
||||
|
} |
||||
|
|
||||
|
private bool IsVirtualFile(Context context, AndroidUri uri) |
||||
|
{ |
||||
|
if (!DocumentsContract.IsDocumentUri(context, uri)) |
||||
|
return false; |
||||
|
|
||||
|
var value = GetColumnValue(context, uri, DocumentsContract.Document.ColumnFlags); |
||||
|
if (!string.IsNullOrEmpty(value) && int.TryParse(value, out var flagsInt)) |
||||
|
{ |
||||
|
var flags = (DocumentContractFlags)flagsInt; |
||||
|
return flags.HasFlag(DocumentContractFlags.VirtualDocument); |
||||
|
} |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
private Stream? GetVirtualFileStream(Context context, AndroidUri uri, bool isOutput) |
||||
|
{ |
||||
|
var mimeTypes = context.ContentResolver?.GetStreamTypes(uri, FilePickerFileTypes.All.MimeTypes![0]); |
||||
|
if (mimeTypes?.Length >= 1) |
||||
|
{ |
||||
|
var mimeType = mimeTypes[0]; |
||||
|
var asset = context.ContentResolver! |
||||
|
.OpenTypedAssetFileDescriptor(uri, mimeType, null); |
||||
|
|
||||
|
var stream = isOutput |
||||
|
? asset?.CreateOutputStream() |
||||
|
: asset?.CreateInputStream(); |
||||
|
|
||||
|
return stream; |
||||
|
} |
||||
|
|
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
public override Task<StorageItemProperties> GetBasicPropertiesAsync() |
||||
|
{ |
||||
|
ulong? size = null; |
||||
|
DateTimeOffset? itemDate = null; |
||||
|
DateTimeOffset? dateModified = null; |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
var projection = new[] |
||||
|
{ |
||||
|
MediaStore.IMediaColumns.Size, MediaStore.IMediaColumns.DateAdded, |
||||
|
MediaStore.IMediaColumns.DateModified |
||||
|
}; |
||||
|
using var cursor = Context.ContentResolver!.Query(Uri, projection, null, null, null); |
||||
|
|
||||
|
if (cursor?.MoveToFirst() == true) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var columnIndex = cursor.GetColumnIndex(MediaStore.IMediaColumns.Size); |
||||
|
if (columnIndex != -1) |
||||
|
{ |
||||
|
size = (ulong)cursor.GetLong(columnIndex); |
||||
|
} |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)? |
||||
|
.Log(this, "File Size metadata reader failed: '{Exception}'", ex); |
||||
|
} |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
var columnIndex = cursor.GetColumnIndex(MediaStore.IMediaColumns.DateAdded); |
||||
|
if (columnIndex != -1) |
||||
|
{ |
||||
|
var longValue = cursor.GetLong(columnIndex); |
||||
|
itemDate = longValue > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(longValue) : null; |
||||
|
} |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)? |
||||
|
.Log(this, "File DateAdded metadata reader failed: '{Exception}'", ex); |
||||
|
} |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
var columnIndex = cursor.GetColumnIndex(MediaStore.IMediaColumns.DateModified); |
||||
|
if (columnIndex != -1) |
||||
|
{ |
||||
|
var longValue = cursor.GetLong(columnIndex); |
||||
|
dateModified = longValue > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(longValue) : null; |
||||
|
} |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)? |
||||
|
.Log(this, "File DateAdded metadata reader failed: '{Exception}'", ex); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
catch (UnsupportedOperationException) |
||||
|
{ |
||||
|
// It's not possible to get parameters of some files/folders.
|
||||
|
} |
||||
|
|
||||
|
return Task.FromResult(new StorageItemProperties(size, itemDate, dateModified)); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,177 @@ |
|||||
|
#nullable enable |
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
using Android.App; |
||||
|
using Android.Content; |
||||
|
using Android.Provider; |
||||
|
using Avalonia.Platform.Storage; |
||||
|
using AndroidUri = Android.Net.Uri; |
||||
|
|
||||
|
namespace Avalonia.Android.Platform.Storage; |
||||
|
|
||||
|
internal class AndroidStorageProvider : IStorageProvider |
||||
|
{ |
||||
|
private readonly AvaloniaActivity _activity; |
||||
|
private int _lastRequestCode = 20000; |
||||
|
|
||||
|
public AndroidStorageProvider(AvaloniaActivity activity) |
||||
|
{ |
||||
|
_activity = activity; |
||||
|
} |
||||
|
|
||||
|
public bool CanOpen => OperatingSystem.IsAndroidVersionAtLeast(19); |
||||
|
|
||||
|
public bool CanSave => OperatingSystem.IsAndroidVersionAtLeast(19); |
||||
|
|
||||
|
public bool CanPickFolder => OperatingSystem.IsAndroidVersionAtLeast(21); |
||||
|
|
||||
|
public Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark) |
||||
|
{ |
||||
|
var uri = AndroidUri.Parse(bookmark) ?? throw new ArgumentException("Couldn't parse Bookmark value", nameof(bookmark)); |
||||
|
return Task.FromResult<IStorageBookmarkFolder?>(new AndroidStorageFolder(_activity, uri)); |
||||
|
} |
||||
|
|
||||
|
public Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark) |
||||
|
{ |
||||
|
var uri = AndroidUri.Parse(bookmark) ?? throw new ArgumentException("Couldn't parse Bookmark value", nameof(bookmark)); |
||||
|
return Task.FromResult<IStorageBookmarkFile?>(new AndroidStorageFile(_activity, uri)); |
||||
|
} |
||||
|
|
||||
|
public async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options) |
||||
|
{ |
||||
|
var mimeTypes = options.FileTypeFilter?.Where(t => t != FilePickerFileTypes.All) |
||||
|
.SelectMany(f => f.MimeTypes ?? Array.Empty<string>()).Distinct().ToArray() ?? Array.Empty<string>(); |
||||
|
|
||||
|
var intent = new Intent(Intent.ActionOpenDocument) |
||||
|
.AddCategory(Intent.CategoryOpenable) |
||||
|
.PutExtra(Intent.ExtraAllowMultiple, options.AllowMultiple) |
||||
|
.SetType(FilePickerFileTypes.All.MimeTypes![0]); |
||||
|
if (mimeTypes.Length > 0) |
||||
|
{ |
||||
|
intent = intent.PutExtra(Intent.ExtraMimeTypes, mimeTypes); |
||||
|
} |
||||
|
|
||||
|
if (TryGetInitialUri(options.SuggestedStartLocation) is { } initialUri) |
||||
|
{ |
||||
|
intent = intent.PutExtra(DocumentsContract.ExtraInitialUri, initialUri); |
||||
|
} |
||||
|
|
||||
|
var pickerIntent = Intent.CreateChooser(intent, options.Title ?? "Select file"); |
||||
|
|
||||
|
var uris = await StartActivity(pickerIntent, false); |
||||
|
return uris.Select(u => new AndroidStorageFile(_activity, u)).ToArray(); |
||||
|
} |
||||
|
|
||||
|
public async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options) |
||||
|
{ |
||||
|
var mimeTypes = options.FileTypeChoices?.Where(t => t != FilePickerFileTypes.All) |
||||
|
.SelectMany(f => f.MimeTypes ?? Array.Empty<string>()).Distinct().ToArray() ?? Array.Empty<string>(); |
||||
|
|
||||
|
var intent = new Intent(Intent.ActionCreateDocument) |
||||
|
.AddCategory(Intent.CategoryOpenable) |
||||
|
.SetType(FilePickerFileTypes.All.MimeTypes![0]); |
||||
|
if (mimeTypes.Length > 0) |
||||
|
{ |
||||
|
intent = intent.PutExtra(Intent.ExtraMimeTypes, mimeTypes); |
||||
|
} |
||||
|
|
||||
|
if (options.SuggestedFileName is { } fileName) |
||||
|
{ |
||||
|
if (options.DefaultExtension is { } ext) |
||||
|
{ |
||||
|
fileName += ext.StartsWith('.') ? ext : "." + ext; |
||||
|
} |
||||
|
intent = intent.PutExtra(Intent.ExtraTitle, fileName); |
||||
|
} |
||||
|
|
||||
|
if (TryGetInitialUri(options.SuggestedStartLocation) is { } initialUri) |
||||
|
{ |
||||
|
intent = intent.PutExtra(DocumentsContract.ExtraInitialUri, initialUri); |
||||
|
} |
||||
|
|
||||
|
var pickerIntent = Intent.CreateChooser(intent, options.Title ?? "Save file"); |
||||
|
|
||||
|
var uris = await StartActivity(pickerIntent, true); |
||||
|
return uris.Select(u => new AndroidStorageFile(_activity, u)).FirstOrDefault(); |
||||
|
} |
||||
|
|
||||
|
public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options) |
||||
|
{ |
||||
|
var intent = new Intent(Intent.ActionOpenDocumentTree) |
||||
|
.PutExtra(Intent.ExtraAllowMultiple, options.AllowMultiple); |
||||
|
if (TryGetInitialUri(options.SuggestedStartLocation) is { } initialUri) |
||||
|
{ |
||||
|
intent = intent.PutExtra(DocumentsContract.ExtraInitialUri, initialUri); |
||||
|
} |
||||
|
|
||||
|
var pickerIntent = Intent.CreateChooser(intent, options.Title ?? "Select folder"); |
||||
|
|
||||
|
var uris = await StartActivity(pickerIntent, false); |
||||
|
return uris.Select(u => new AndroidStorageFolder(_activity, u)).ToArray(); |
||||
|
} |
||||
|
|
||||
|
private async Task<List<AndroidUri>> StartActivity(Intent? pickerIntent, bool singleResult) |
||||
|
{ |
||||
|
var resultList = new List<AndroidUri>(1); |
||||
|
var tcs = new TaskCompletionSource<Intent?>(); |
||||
|
var currentRequestCode = _lastRequestCode++; |
||||
|
|
||||
|
_activity.ActivityResult += OnActivityResult; |
||||
|
_activity.StartActivityForResult(pickerIntent, currentRequestCode); |
||||
|
|
||||
|
var result = await tcs.Task; |
||||
|
|
||||
|
if (result != null) |
||||
|
{ |
||||
|
// ClipData first to avoid issue with multiple files selection.
|
||||
|
if (!singleResult && result.ClipData is { } clipData) |
||||
|
{ |
||||
|
for (var i = 0; i < clipData.ItemCount; i++) |
||||
|
{ |
||||
|
var uri = clipData.GetItemAt(i)?.Uri; |
||||
|
if (uri != null) |
||||
|
{ |
||||
|
resultList.Add(uri); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
else if (result.Data is { } uri) |
||||
|
{ |
||||
|
resultList.Add(uri); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (result?.HasExtra("error") == true) |
||||
|
{ |
||||
|
throw new Exception(result.GetStringExtra("error")); |
||||
|
} |
||||
|
|
||||
|
return resultList; |
||||
|
|
||||
|
void OnActivityResult(int requestCode, Result resultCode, Intent data) |
||||
|
{ |
||||
|
if (currentRequestCode != requestCode) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
_activity.ActivityResult -= OnActivityResult; |
||||
|
|
||||
|
_ = tcs.TrySetResult(resultCode == Result.Ok ? data : null); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static AndroidUri? TryGetInitialUri(IStorageFolder? folder) |
||||
|
{ |
||||
|
if (OperatingSystem.IsAndroidVersionAtLeast(26) |
||||
|
&& (folder as AndroidStorageItem)?.Uri is { } uri) |
||||
|
{ |
||||
|
return uri; |
||||
|
} |
||||
|
|
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
@ -1,20 +0,0 @@ |
|||||
using System; |
|
||||
using System.Threading.Tasks; |
|
||||
using Avalonia.Controls; |
|
||||
using Avalonia.Controls.Platform; |
|
||||
|
|
||||
namespace Avalonia.Android |
|
||||
{ |
|
||||
internal class SystemDialogImpl : ISystemDialogImpl |
|
||||
{ |
|
||||
public Task<string[]> ShowFileDialogAsync(FileDialog dialog, Window parent) |
|
||||
{ |
|
||||
throw new NotImplementedException(); |
|
||||
} |
|
||||
|
|
||||
public Task<string> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) |
|
||||
{ |
|
||||
throw new NotImplementedException(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,107 @@ |
|||||
|
using System; |
||||
|
using System.Diagnostics.CodeAnalysis; |
||||
|
using System.IO; |
||||
|
using System.Security; |
||||
|
using System.Threading.Tasks; |
||||
|
using Avalonia.Metadata; |
||||
|
|
||||
|
namespace Avalonia.Platform.Storage.FileIO; |
||||
|
|
||||
|
[Unstable] |
||||
|
public class BclStorageFile : IStorageBookmarkFile |
||||
|
{ |
||||
|
private readonly FileInfo _fileInfo; |
||||
|
|
||||
|
public BclStorageFile(FileInfo fileInfo) |
||||
|
{ |
||||
|
_fileInfo = fileInfo ?? throw new ArgumentNullException(nameof(fileInfo)); |
||||
|
} |
||||
|
|
||||
|
public bool CanOpenRead => true; |
||||
|
|
||||
|
public bool CanOpenWrite => true; |
||||
|
|
||||
|
public string Name => _fileInfo.Name; |
||||
|
|
||||
|
public virtual bool CanBookmark => true; |
||||
|
|
||||
|
public Task<StorageItemProperties> GetBasicPropertiesAsync() |
||||
|
{ |
||||
|
var props = new StorageItemProperties(); |
||||
|
if (_fileInfo.Exists) |
||||
|
{ |
||||
|
props = new StorageItemProperties( |
||||
|
(ulong)_fileInfo.Length, |
||||
|
_fileInfo.CreationTimeUtc, |
||||
|
_fileInfo.LastAccessTimeUtc); |
||||
|
} |
||||
|
return Task.FromResult(props); |
||||
|
} |
||||
|
|
||||
|
public Task<IStorageFolder?> GetParentAsync() |
||||
|
{ |
||||
|
if (_fileInfo.Directory is { } directory) |
||||
|
{ |
||||
|
return Task.FromResult<IStorageFolder?>(new BclStorageFolder(directory)); |
||||
|
} |
||||
|
return Task.FromResult<IStorageFolder?>(null); |
||||
|
} |
||||
|
|
||||
|
public Task<Stream> OpenRead() |
||||
|
{ |
||||
|
return Task.FromResult<Stream>(_fileInfo.OpenRead()); |
||||
|
} |
||||
|
|
||||
|
public Task<Stream> OpenWrite() |
||||
|
{ |
||||
|
return Task.FromResult<Stream>(_fileInfo.OpenWrite()); |
||||
|
} |
||||
|
|
||||
|
public virtual Task<string?> SaveBookmark() |
||||
|
{ |
||||
|
return Task.FromResult<string?>(_fileInfo.FullName); |
||||
|
} |
||||
|
|
||||
|
public Task ReleaseBookmark() |
||||
|
{ |
||||
|
// No-op
|
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
|
||||
|
public bool TryGetUri([NotNullWhen(true)] out Uri? uri) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
if (_fileInfo.Directory is not null) |
||||
|
{ |
||||
|
uri = Path.IsPathRooted(_fileInfo.FullName) ? |
||||
|
new Uri(new Uri("file://"), _fileInfo.FullName) : |
||||
|
new Uri(_fileInfo.FullName, UriKind.Relative); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
uri = null; |
||||
|
return false; |
||||
|
} |
||||
|
catch (SecurityException) |
||||
|
{ |
||||
|
uri = null; |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected virtual void Dispose(bool disposing) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
~BclStorageFile() |
||||
|
{ |
||||
|
Dispose(disposing: false); |
||||
|
} |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
Dispose(disposing: true); |
||||
|
GC.SuppressFinalize(this); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,88 @@ |
|||||
|
using System; |
||||
|
using System.Diagnostics.CodeAnalysis; |
||||
|
using System.IO; |
||||
|
using System.Security; |
||||
|
using System.Threading.Tasks; |
||||
|
using Avalonia.Metadata; |
||||
|
|
||||
|
namespace Avalonia.Platform.Storage.FileIO; |
||||
|
|
||||
|
[Unstable] |
||||
|
public class BclStorageFolder : IStorageBookmarkFolder |
||||
|
{ |
||||
|
private readonly DirectoryInfo _directoryInfo; |
||||
|
|
||||
|
public BclStorageFolder(DirectoryInfo directoryInfo) |
||||
|
{ |
||||
|
_directoryInfo = directoryInfo ?? throw new ArgumentNullException(nameof(directoryInfo)); |
||||
|
if (!_directoryInfo.Exists) |
||||
|
{ |
||||
|
throw new ArgumentException("Directory must exist", nameof(directoryInfo)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public string Name => _directoryInfo.Name; |
||||
|
|
||||
|
public bool CanBookmark => true; |
||||
|
|
||||
|
public Task<StorageItemProperties> GetBasicPropertiesAsync() |
||||
|
{ |
||||
|
var props = new StorageItemProperties( |
||||
|
null, |
||||
|
_directoryInfo.CreationTimeUtc, |
||||
|
_directoryInfo.LastAccessTimeUtc); |
||||
|
return Task.FromResult(props); |
||||
|
} |
||||
|
|
||||
|
public Task<IStorageFolder?> GetParentAsync() |
||||
|
{ |
||||
|
if (_directoryInfo.Parent is { } directory) |
||||
|
{ |
||||
|
return Task.FromResult<IStorageFolder?>(new BclStorageFolder(directory)); |
||||
|
} |
||||
|
return Task.FromResult<IStorageFolder?>(null); |
||||
|
} |
||||
|
|
||||
|
public virtual Task<string?> SaveBookmark() |
||||
|
{ |
||||
|
return Task.FromResult<string?>(_directoryInfo.FullName); |
||||
|
} |
||||
|
|
||||
|
public Task ReleaseBookmark() |
||||
|
{ |
||||
|
// No-op
|
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
|
||||
|
public bool TryGetUri([NotNullWhen(true)] out Uri? uri) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
uri = Path.IsPathRooted(_directoryInfo.FullName) ? |
||||
|
new Uri(new Uri("file://"), _directoryInfo.FullName) : |
||||
|
new Uri(_directoryInfo.FullName, UriKind.Relative); |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
catch (SecurityException) |
||||
|
{ |
||||
|
uri = null; |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected virtual void Dispose(bool disposing) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
~BclStorageFolder() |
||||
|
{ |
||||
|
Dispose(disposing: false); |
||||
|
} |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
Dispose(disposing: true); |
||||
|
GC.SuppressFinalize(this); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,35 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
using System.IO; |
||||
|
using System.Threading.Tasks; |
||||
|
using Avalonia.Metadata; |
||||
|
|
||||
|
namespace Avalonia.Platform.Storage.FileIO; |
||||
|
|
||||
|
[Unstable] |
||||
|
public abstract class BclStorageProvider : IStorageProvider |
||||
|
{ |
||||
|
public abstract bool CanOpen { get; } |
||||
|
public abstract Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options); |
||||
|
|
||||
|
public abstract bool CanSave { get; } |
||||
|
public abstract Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options); |
||||
|
|
||||
|
public abstract bool CanPickFolder { get; } |
||||
|
public abstract Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options); |
||||
|
|
||||
|
public virtual Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark) |
||||
|
{ |
||||
|
var file = new FileInfo(bookmark); |
||||
|
return file.Exists |
||||
|
? Task.FromResult<IStorageBookmarkFile?>(new BclStorageFile(file)) |
||||
|
: Task.FromResult<IStorageBookmarkFile?>(null); |
||||
|
} |
||||
|
|
||||
|
public virtual Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark) |
||||
|
{ |
||||
|
var folder = new DirectoryInfo(bookmark); |
||||
|
return folder.Exists |
||||
|
? Task.FromResult<IStorageBookmarkFolder?>(new BclStorageFolder(folder)) |
||||
|
: Task.FromResult<IStorageBookmarkFolder?>(null); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,40 @@ |
|||||
|
using System; |
||||
|
using System.IO; |
||||
|
using System.Linq; |
||||
|
using Avalonia.Metadata; |
||||
|
|
||||
|
namespace Avalonia.Platform.Storage.FileIO; |
||||
|
|
||||
|
[Unstable] |
||||
|
public static class StorageProviderHelpers |
||||
|
{ |
||||
|
public static string NameWithExtension(string path, string? defaultExtension, FilePickerFileType? filter) |
||||
|
{ |
||||
|
var name = Path.GetFileName(path); |
||||
|
if (name != null && !Path.HasExtension(name)) |
||||
|
{ |
||||
|
if (filter?.Patterns?.Count > 0) |
||||
|
{ |
||||
|
if (defaultExtension != null |
||||
|
&& filter.Patterns.Contains(defaultExtension)) |
||||
|
{ |
||||
|
return Path.ChangeExtension(path, defaultExtension.TrimStart('.')); |
||||
|
} |
||||
|
|
||||
|
var ext = filter.Patterns.FirstOrDefault(x => x != "*.*"); |
||||
|
ext = ext?.Split(new[] { "*." }, StringSplitOptions.RemoveEmptyEntries).LastOrDefault(); |
||||
|
if (ext != null) |
||||
|
{ |
||||
|
return Path.ChangeExtension(path, ext); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (defaultExtension != null) |
||||
|
{ |
||||
|
return Path.ChangeExtension(path, defaultExtension); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return path; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,44 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
|
||||
|
namespace Avalonia.Platform.Storage; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Represents a name mapped to the associated file types (extensions).
|
||||
|
/// </summary>
|
||||
|
public sealed class FilePickerFileType |
||||
|
{ |
||||
|
public FilePickerFileType(string name) |
||||
|
{ |
||||
|
Name = name; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// File type name.
|
||||
|
/// </summary>
|
||||
|
public string Name { get; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// List of extensions in GLOB format. I.e. "*.png" or "*.*".
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// Used on Windows and Linux systems.
|
||||
|
/// </remarks>
|
||||
|
public IReadOnlyList<string>? Patterns { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// List of extensions in MIME format.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// Used on Android, Browser and Linux systems.
|
||||
|
/// </remarks>
|
||||
|
public IReadOnlyList<string>? MimeTypes { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// List of extensions in Apple uniform format.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// Used only on Apple devices.
|
||||
|
/// See https://developer.apple.com/documentation/uniformtypeidentifiers/system_declared_uniform_type_identifiers.
|
||||
|
/// </remarks>
|
||||
|
public IReadOnlyList<string>? AppleUniformTypeIdentifiers { get; set; } |
||||
|
} |
||||
@ -0,0 +1,48 @@ |
|||||
|
namespace Avalonia.Platform.Storage; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Dictionary of well known file types.
|
||||
|
/// </summary>
|
||||
|
public static class FilePickerFileTypes |
||||
|
{ |
||||
|
public static FilePickerFileType All { get; } = new("All") |
||||
|
{ |
||||
|
Patterns = new[] { "*.*" }, |
||||
|
MimeTypes = new[] { "*/*" } |
||||
|
}; |
||||
|
|
||||
|
public static FilePickerFileType TextPlain { get; } = new("Plain Text") |
||||
|
{ |
||||
|
Patterns = new[] { "*.txt" }, |
||||
|
AppleUniformTypeIdentifiers = new[] { "public.plain-text" }, |
||||
|
MimeTypes = new[] { "text/plain" } |
||||
|
}; |
||||
|
|
||||
|
public static FilePickerFileType ImageAll { get; } = new("All Images") |
||||
|
{ |
||||
|
Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp" }, |
||||
|
AppleUniformTypeIdentifiers = new[] { "public.image" }, |
||||
|
MimeTypes = new[] { "image/*" } |
||||
|
}; |
||||
|
|
||||
|
public static FilePickerFileType ImageJpg { get; } = new("JPEG image") |
||||
|
{ |
||||
|
Patterns = new[] { "*.jpg", "*.jpeg" }, |
||||
|
AppleUniformTypeIdentifiers = new[] { "public.jpeg" }, |
||||
|
MimeTypes = new[] { "image/jpeg" } |
||||
|
}; |
||||
|
|
||||
|
public static FilePickerFileType ImagePng { get; } = new("PNG image") |
||||
|
{ |
||||
|
Patterns = new[] { "*.png" }, |
||||
|
AppleUniformTypeIdentifiers = new[] { "public.png" }, |
||||
|
MimeTypes = new[] { "image/png" } |
||||
|
}; |
||||
|
|
||||
|
public static FilePickerFileType Pdf { get; } = new("PDF document") |
||||
|
{ |
||||
|
Patterns = new[] { "*.pdf" }, |
||||
|
AppleUniformTypeIdentifiers = new[] { "com.adobe.pdf" }, |
||||
|
MimeTypes = new[] { "application/pdf" } |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
|
||||
|
namespace Avalonia.Platform.Storage; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Options class for <see cref="IStorageProvider.OpenFilePickerAsync"/> method.
|
||||
|
/// </summary>
|
||||
|
public class FilePickerOpenOptions : PickerOptions |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Gets or sets an option indicating whether open picker allows users to select multiple files.
|
||||
|
/// </summary>
|
||||
|
public bool AllowMultiple { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets or sets the collection of file types that the file open picker displays.
|
||||
|
/// </summary>
|
||||
|
public IReadOnlyList<FilePickerFileType>? FileTypeFilter { get; set; } |
||||
|
} |
||||
@ -0,0 +1,29 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
|
||||
|
namespace Avalonia.Platform.Storage; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Options class for <see cref="IStorageProvider.SaveFilePickerAsync"/> method.
|
||||
|
/// </summary>
|
||||
|
public class FilePickerSaveOptions : PickerOptions |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Gets or sets the file name that the file save picker suggests to the user.
|
||||
|
/// </summary>
|
||||
|
public string? SuggestedFileName { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets or sets the default extension to be used to save the file.
|
||||
|
/// </summary>
|
||||
|
public string? DefaultExtension { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets or sets the collection of valid file types that the user can choose to assign to a file.
|
||||
|
/// </summary>
|
||||
|
public IReadOnlyList<FilePickerFileType>? FileTypeChoices { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets or sets a value indicating whether file open picker displays a warning if the user specifies the name of a file that already exists.
|
||||
|
/// </summary>
|
||||
|
public bool? ShowOverwritePrompt { get; set; } |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
namespace Avalonia.Platform.Storage; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Options class for <see cref="IStorageProvider.OpenFolderPickerAsync"/> method.
|
||||
|
/// </summary>
|
||||
|
public class FolderPickerOpenOptions : PickerOptions |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Gets or sets an option indicating whether open picker allows users to select multiple folders.
|
||||
|
/// </summary>
|
||||
|
public bool AllowMultiple { get; set; } |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
using System.Threading.Tasks; |
||||
|
using Avalonia.Metadata; |
||||
|
|
||||
|
namespace Avalonia.Platform.Storage; |
||||
|
|
||||
|
[NotClientImplementable] |
||||
|
public interface IStorageBookmarkItem : IStorageItem |
||||
|
{ |
||||
|
Task ReleaseBookmark(); |
||||
|
} |
||||
|
|
||||
|
[NotClientImplementable] |
||||
|
public interface IStorageBookmarkFile : IStorageFile, IStorageBookmarkItem |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
[NotClientImplementable] |
||||
|
public interface IStorageBookmarkFolder : IStorageFolder, IStorageBookmarkItem |
||||
|
{ |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
using System.IO; |
||||
|
using System.Threading.Tasks; |
||||
|
using Avalonia.Metadata; |
||||
|
|
||||
|
namespace Avalonia.Platform.Storage; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Represents a file. Provides information about the file and its contents, and ways to manipulate them.
|
||||
|
/// </summary>
|
||||
|
[NotClientImplementable] |
||||
|
public interface IStorageFile : IStorageItem |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Returns true, if file is readable.
|
||||
|
/// </summary>
|
||||
|
bool CanOpenRead { get; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Opens a stream for read access.
|
||||
|
/// </summary>
|
||||
|
Task<Stream> OpenRead(); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Returns true, if file is writeable.
|
||||
|
/// </summary>
|
||||
|
bool CanOpenWrite { get; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Opens stream for writing to the file.
|
||||
|
/// </summary>
|
||||
|
Task<Stream> OpenWrite(); |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
using Avalonia.Metadata; |
||||
|
|
||||
|
namespace Avalonia.Platform.Storage; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Manipulates folders and their contents, and provides information about them.
|
||||
|
/// </summary>
|
||||
|
[NotClientImplementable] |
||||
|
public interface IStorageFolder : IStorageItem |
||||
|
{ |
||||
|
} |
||||
@ -0,0 +1,53 @@ |
|||||
|
using System; |
||||
|
using System.Diagnostics.CodeAnalysis; |
||||
|
using System.Threading.Tasks; |
||||
|
using Avalonia.Metadata; |
||||
|
|
||||
|
namespace Avalonia.Platform.Storage; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Manipulates storage items (files and folders) and their contents, and provides information about them
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// This interface inherits <see cref="IDisposable"/> . It's recommended to dispose <see cref="IStorageItem"/> when it's not used anymore.
|
||||
|
/// </remarks>
|
||||
|
[NotClientImplementable] |
||||
|
public interface IStorageItem : IDisposable |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Gets the name of the item including the file name extension if there is one.
|
||||
|
/// </summary>
|
||||
|
string Name { get; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the full file-system path of the item, if the item has a path.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// Android backend might return file path with "content:" scheme.
|
||||
|
/// Browser and iOS backends might return relative uris.
|
||||
|
/// </remarks>
|
||||
|
bool TryGetUri([NotNullWhen(true)] out Uri? uri); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the basic properties of the current item.
|
||||
|
/// </summary>
|
||||
|
Task<StorageItemProperties> GetBasicPropertiesAsync(); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Returns true is item can be bookmarked and reused later.
|
||||
|
/// </summary>
|
||||
|
bool CanBookmark { get; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Saves items to a bookmark.
|
||||
|
/// </summary>
|
||||
|
/// <returns>
|
||||
|
/// Returns identifier of a bookmark. Can be null if OS denied request.
|
||||
|
/// </returns>
|
||||
|
Task<string?> SaveBookmark(); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the parent folder of the current storage item.
|
||||
|
/// </summary>
|
||||
|
Task<IStorageFolder?> GetParentAsync(); |
||||
|
} |
||||
@ -0,0 +1,56 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
using Avalonia.Metadata; |
||||
|
|
||||
|
namespace Avalonia.Platform.Storage; |
||||
|
|
||||
|
[NotClientImplementable] |
||||
|
public interface IStorageProvider |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Returns true if it's possible to open file picker on the current platform.
|
||||
|
/// </summary>
|
||||
|
bool CanOpen { get; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Opens file picker dialog.
|
||||
|
/// </summary>
|
||||
|
/// <returns>Array of selected <see cref="IStorageFile"/> or empty collection if user canceled the dialog.</returns>
|
||||
|
Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Returns true if it's possible to open save file picker on the current platform.
|
||||
|
/// </summary>
|
||||
|
bool CanSave { get; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Opens save file picker dialog.
|
||||
|
/// </summary>
|
||||
|
/// <returns>Saved <see cref="IStorageFile"/> or null if user canceled the dialog.</returns>
|
||||
|
Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Returns true if it's possible to open folder picker on the current platform.
|
||||
|
/// </summary>
|
||||
|
bool CanPickFolder { get; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Opens folder picker dialog.
|
||||
|
/// </summary>
|
||||
|
/// <returns>Array of selected <see cref="IStorageFolder"/> or empty collection if user canceled the dialog.</returns>
|
||||
|
Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Open <see cref="IStorageBookmarkFile"/> from the bookmark ID.
|
||||
|
/// </summary>
|
||||
|
/// <param name="bookmark">Bookmark ID.</param>
|
||||
|
/// <returns>Bookmarked file or null if OS denied request.</returns>
|
||||
|
Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Open <see cref="IStorageBookmarkFolder"/> from the bookmark ID.
|
||||
|
/// </summary>
|
||||
|
/// <param name="bookmark">Bookmark ID.</param>
|
||||
|
/// <returns>Bookmarked folder or null if OS denied request.</returns>
|
||||
|
Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark); |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
namespace Avalonia.Platform.Storage; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Common options for <see cref="IStorageProvider.OpenFolderPickerAsync"/>, <see cref="IStorageProvider.OpenFilePickerAsync"/> and <see cref="IStorageProvider.SaveFilePickerAsync"/> methods.
|
||||
|
/// </summary>
|
||||
|
public class PickerOptions |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Gets or sets the text that appears in the title bar of a folder dialog.
|
||||
|
/// </summary>
|
||||
|
public string? Title { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets or sets the initial location where the file open picker looks for files to present to the user.
|
||||
|
/// </summary>
|
||||
|
public IStorageFolder? SuggestedStartLocation { get; set; } |
||||
|
} |
||||
@ -0,0 +1,43 @@ |
|||||
|
using System; |
||||
|
|
||||
|
namespace Avalonia.Platform.Storage; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Provides access to the content-related properties of an item (like a file or folder).
|
||||
|
/// </summary>
|
||||
|
public class StorageItemProperties |
||||
|
{ |
||||
|
public StorageItemProperties( |
||||
|
ulong? size = null, |
||||
|
DateTimeOffset? dateCreated = null, |
||||
|
DateTimeOffset? dateModified = null) |
||||
|
{ |
||||
|
Size = size; |
||||
|
DateCreated = dateCreated; |
||||
|
DateModified = dateModified; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the size of the file in bytes.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// Can be null if property is not available.
|
||||
|
/// </remarks>
|
||||
|
public ulong? Size { get; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the date and time that the current folder was created.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// Can be null if property is not available.
|
||||
|
/// </remarks>
|
||||
|
public DateTimeOffset? DateCreated { get; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the date and time of the last time the file was modified.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// Can be null if property is not available.
|
||||
|
/// </remarks>
|
||||
|
public DateTimeOffset? DateModified { get; } |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
#nullable enable |
||||
|
using Avalonia.Platform.Storage; |
||||
|
|
||||
|
namespace Avalonia.Controls.Platform; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Factory allows to register custom storage provider instead of native implementation.
|
||||
|
/// </summary>
|
||||
|
public interface IStorageProviderFactory |
||||
|
{ |
||||
|
IStorageProvider CreateProvider(TopLevel topLevel); |
||||
|
} |
||||
@ -0,0 +1,74 @@ |
|||||
|
using System; |
||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
#nullable enable |
||||
|
|
||||
|
namespace Avalonia.Controls.Platform |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Defines a platform-specific system dialog implementation.
|
||||
|
/// </summary>
|
||||
|
[Obsolete] |
||||
|
internal class SystemDialogImpl : ISystemDialogImpl |
||||
|
{ |
||||
|
public async Task<string[]?> ShowFileDialogAsync(FileDialog dialog, Window parent) |
||||
|
{ |
||||
|
if (dialog is OpenFileDialog openDialog) |
||||
|
{ |
||||
|
var filePicker = parent.StorageProvider; |
||||
|
if (!filePicker.CanOpen) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
var options = openDialog.ToFilePickerOpenOptions(); |
||||
|
|
||||
|
var files = await filePicker.OpenFilePickerAsync(options); |
||||
|
return files |
||||
|
.Select(file => file.TryGetUri(out var fullPath) |
||||
|
? fullPath.LocalPath |
||||
|
: file.Name) |
||||
|
.ToArray(); |
||||
|
} |
||||
|
else if (dialog is SaveFileDialog saveDialog) |
||||
|
{ |
||||
|
var filePicker = parent.StorageProvider; |
||||
|
if (!filePicker.CanSave) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
var options = saveDialog.ToFilePickerSaveOptions(); |
||||
|
|
||||
|
var file = await filePicker.SaveFilePickerAsync(options); |
||||
|
if (file is null) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
var filePath = file.TryGetUri(out var fullPath) |
||||
|
? fullPath.LocalPath |
||||
|
: file.Name; |
||||
|
return new[] { filePath }; |
||||
|
} |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
public async Task<string?> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) |
||||
|
{ |
||||
|
var filePicker = parent.StorageProvider; |
||||
|
if (!filePicker.CanPickFolder) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
var options = dialog.ToFolderPickerOpenOptions(); |
||||
|
|
||||
|
var folders = await filePicker.OpenFolderPickerAsync(options); |
||||
|
return folders |
||||
|
.Select(f => f.TryGetUri(out var uri) ? uri.LocalPath : null) |
||||
|
.FirstOrDefault(u => u is not null); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
using Avalonia.Metadata; |
||||
|
using Avalonia.Platform; |
||||
|
using Avalonia.Platform.Storage; |
||||
|
|
||||
|
namespace Avalonia.Controls.Platform; |
||||
|
|
||||
|
[Unstable] |
||||
|
public interface ITopLevelImplWithStorageProvider : ITopLevelImpl |
||||
|
{ |
||||
|
public IStorageProvider StorageProvider { get; } |
||||
|
} |
||||
@ -0,0 +1,147 @@ |
|||||
|
#nullable enable |
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.IO; |
||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
using Avalonia.Controls; |
||||
|
using Avalonia.Platform.Storage; |
||||
|
using Avalonia.Platform.Storage.FileIO; |
||||
|
|
||||
|
namespace Avalonia.Dialogs; |
||||
|
|
||||
|
public class ManagedStorageProvider<T> : BclStorageProvider where T : Window, new() |
||||
|
{ |
||||
|
private readonly Window _parent; |
||||
|
private readonly ManagedFileDialogOptions _managedOptions; |
||||
|
|
||||
|
public ManagedStorageProvider(Window parent, ManagedFileDialogOptions? managedOptions) |
||||
|
{ |
||||
|
_parent = parent; |
||||
|
_managedOptions = managedOptions ?? new ManagedFileDialogOptions(); |
||||
|
} |
||||
|
|
||||
|
public override bool CanSave => true; |
||||
|
public override bool CanOpen => true; |
||||
|
public override bool CanPickFolder => true; |
||||
|
|
||||
|
public override async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options) |
||||
|
{ |
||||
|
var model = new ManagedFileChooserViewModel(options, _managedOptions); |
||||
|
var results = await Show(model, _parent); |
||||
|
|
||||
|
return results.Select(f => new BclStorageFile(new FileInfo(f))).ToArray(); |
||||
|
} |
||||
|
|
||||
|
public override async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options) |
||||
|
{ |
||||
|
var model = new ManagedFileChooserViewModel(options, _managedOptions); |
||||
|
var results = await Show(model, _parent); |
||||
|
|
||||
|
return results.FirstOrDefault() is { } result |
||||
|
? new BclStorageFile(new FileInfo(result)) |
||||
|
: null; |
||||
|
} |
||||
|
|
||||
|
public override async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options) |
||||
|
{ |
||||
|
var model = new ManagedFileChooserViewModel(options, _managedOptions); |
||||
|
var results = await Show(model, _parent); |
||||
|
|
||||
|
return results.Select(f => new BclStorageFolder(new DirectoryInfo(f))).ToArray(); |
||||
|
} |
||||
|
|
||||
|
private async Task<string[]> Show(ManagedFileChooserViewModel model, Window parent) |
||||
|
{ |
||||
|
var dialog = new T |
||||
|
{ |
||||
|
Content = new ManagedFileChooser(), |
||||
|
Title = model.Title, |
||||
|
DataContext = model |
||||
|
}; |
||||
|
|
||||
|
dialog.Closed += delegate { model.Cancel(); }; |
||||
|
|
||||
|
string[]? result = null; |
||||
|
|
||||
|
model.CompleteRequested += items => |
||||
|
{ |
||||
|
result = items; |
||||
|
dialog.Close(); |
||||
|
}; |
||||
|
|
||||
|
model.OverwritePrompt += async (filename) => |
||||
|
{ |
||||
|
var overwritePromptDialog = new Window() |
||||
|
{ |
||||
|
Title = "Confirm Save As", |
||||
|
SizeToContent = SizeToContent.WidthAndHeight, |
||||
|
WindowStartupLocation = WindowStartupLocation.CenterOwner, |
||||
|
Padding = new Thickness(10), |
||||
|
MinWidth = 270 |
||||
|
}; |
||||
|
|
||||
|
string name = Path.GetFileName(filename); |
||||
|
|
||||
|
var panel = new DockPanel() |
||||
|
{ |
||||
|
HorizontalAlignment = Layout.HorizontalAlignment.Stretch |
||||
|
}; |
||||
|
|
||||
|
var label = new Label() |
||||
|
{ |
||||
|
Content = $"{name} already exists.\nDo you want to replace it?" |
||||
|
}; |
||||
|
|
||||
|
panel.Children.Add(label); |
||||
|
DockPanel.SetDock(label, Dock.Top); |
||||
|
|
||||
|
var buttonPanel = new StackPanel() |
||||
|
{ |
||||
|
HorizontalAlignment = Layout.HorizontalAlignment.Right, |
||||
|
Orientation = Layout.Orientation.Horizontal, |
||||
|
Spacing = 10 |
||||
|
}; |
||||
|
|
||||
|
var button = new Button() |
||||
|
{ |
||||
|
Content = "Yes", |
||||
|
HorizontalAlignment = Layout.HorizontalAlignment.Right |
||||
|
}; |
||||
|
|
||||
|
button.Click += (sender, args) => |
||||
|
{ |
||||
|
result = new string[1] { filename }; |
||||
|
overwritePromptDialog.Close(); |
||||
|
dialog.Close(); |
||||
|
}; |
||||
|
|
||||
|
buttonPanel.Children.Add(button); |
||||
|
|
||||
|
button = new Button() |
||||
|
{ |
||||
|
Content = "No", |
||||
|
HorizontalAlignment = Layout.HorizontalAlignment.Right |
||||
|
}; |
||||
|
|
||||
|
button.Click += (sender, args) => |
||||
|
{ |
||||
|
overwritePromptDialog.Close(); |
||||
|
}; |
||||
|
|
||||
|
buttonPanel.Children.Add(button); |
||||
|
|
||||
|
panel.Children.Add(buttonPanel); |
||||
|
DockPanel.SetDock(buttonPanel, Dock.Bottom); |
||||
|
|
||||
|
overwritePromptDialog.Content = panel; |
||||
|
|
||||
|
await overwritePromptDialog.ShowDialog(dialog); |
||||
|
}; |
||||
|
|
||||
|
model.CancelRequested += dialog.Close; |
||||
|
|
||||
|
await dialog.ShowDialog<object>(parent); |
||||
|
return result ?? Array.Empty<string>(); |
||||
|
} |
||||
|
} |
||||
@ -1,102 +1,171 @@ |
|||||
using System; |
using System; |
||||
using System.Collections.Generic; |
using System.Collections.Generic; |
||||
|
using System.IO; |
||||
using System.Linq; |
using System.Linq; |
||||
using System.Text; |
using System.Text; |
||||
using System.Threading.Tasks; |
using System.Threading.Tasks; |
||||
using Avalonia.Controls; |
|
||||
using Avalonia.Controls.Platform; |
|
||||
using Avalonia.Logging; |
using Avalonia.Logging; |
||||
|
using Avalonia.Platform; |
||||
|
using Avalonia.Platform.Storage; |
||||
|
using Avalonia.Platform.Storage.FileIO; |
||||
|
|
||||
using Tmds.DBus; |
using Tmds.DBus; |
||||
|
|
||||
namespace Avalonia.FreeDesktop |
namespace Avalonia.FreeDesktop |
||||
{ |
{ |
||||
internal class DBusSystemDialog : ISystemDialogImpl |
internal class DBusSystemDialog : BclStorageProvider |
||||
{ |
{ |
||||
private readonly IFileChooser _fileChooser; |
private static readonly Lazy<IFileChooser?> s_fileChooser = new(() => |
||||
|
|
||||
internal static DBusSystemDialog? TryCreate() |
|
||||
{ |
{ |
||||
var fileChooser = DBusHelper.Connection?.CreateProxy<IFileChooser>("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop"); |
var fileChooser = DBusHelper.Connection?.CreateProxy<IFileChooser>("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop"); |
||||
if (fileChooser is null) |
if (fileChooser is null) |
||||
return null; |
return null; |
||||
try |
try |
||||
{ |
{ |
||||
fileChooser.GetVersionAsync().GetAwaiter().GetResult(); |
_ = fileChooser.GetVersionAsync(); |
||||
return new DBusSystemDialog(fileChooser); |
return fileChooser; |
||||
} |
} |
||||
catch (Exception e) |
catch (Exception e) |
||||
{ |
{ |
||||
Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform)?.Log(null, $"Unable to connect to org.freedesktop.portal.Desktop: {e.Message}"); |
Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform)?.Log(null, $"Unable to connect to org.freedesktop.portal.Desktop: {e.Message}"); |
||||
return null; |
return null; |
||||
} |
} |
||||
|
}); |
||||
|
|
||||
|
internal static DBusSystemDialog? TryCreate(IPlatformHandle handle) |
||||
|
{ |
||||
|
return handle.HandleDescriptor == "XID" && s_fileChooser.Value is { } fileChooser |
||||
|
? new DBusSystemDialog(fileChooser, handle) : null; |
||||
} |
} |
||||
|
|
||||
private DBusSystemDialog(IFileChooser fileChooser) |
private readonly IFileChooser _fileChooser; |
||||
|
private readonly IPlatformHandle _handle; |
||||
|
|
||||
|
private DBusSystemDialog(IFileChooser fileChooser, IPlatformHandle handle) |
||||
{ |
{ |
||||
_fileChooser = fileChooser; |
_fileChooser = fileChooser; |
||||
|
_handle = handle; |
||||
} |
} |
||||
|
|
||||
public async Task<string[]?> ShowFileDialogAsync(FileDialog dialog, Window parent) |
public override bool CanOpen => true; |
||||
|
|
||||
|
public override bool CanSave => true; |
||||
|
|
||||
|
public override bool CanPickFolder => true; |
||||
|
|
||||
|
public override async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options) |
||||
{ |
{ |
||||
var parentWindow = $"x11:{parent.PlatformImpl!.Handle.Handle.ToString("X")}"; |
var parentWindow = $"x11:{_handle.Handle:X}"; |
||||
ObjectPath objectPath; |
ObjectPath objectPath; |
||||
var options = new Dictionary<string, object>(); |
var chooserOptions = new Dictionary<string, object>(); |
||||
if (dialog.Filters is not null) |
var filters = ParseFilters(options.FileTypeFilter); |
||||
options.Add("filters", ParseFilters(dialog)); |
if (filters.Any()) |
||||
|
{ |
||||
|
chooserOptions.Add("filters", filters); |
||||
|
} |
||||
|
|
||||
|
chooserOptions.Add("multiple", options.AllowMultiple); |
||||
|
|
||||
|
objectPath = await _fileChooser.OpenFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions); |
||||
|
|
||||
|
var request = DBusHelper.Connection!.CreateProxy<IRequest>("org.freedesktop.portal.Request", objectPath); |
||||
|
var tsc = new TaskCompletionSource<string[]?>(); |
||||
|
using var disposable = await request.WatchResponseAsync(x => tsc.SetResult(x.results["uris"] as string[]), tsc.SetException); |
||||
|
var uris = await tsc.Task ?? Array.Empty<string>(); |
||||
|
|
||||
|
return uris.Select(path => new BclStorageFile(new FileInfo(new Uri(path).AbsolutePath))).ToList(); |
||||
|
} |
||||
|
|
||||
switch (dialog) |
public override async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options) |
||||
|
{ |
||||
|
var parentWindow = $"x11:{_handle.Handle:X}"; |
||||
|
ObjectPath objectPath; |
||||
|
var chooserOptions = new Dictionary<string, object>(); |
||||
|
var filters = ParseFilters(options.FileTypeChoices); |
||||
|
if (filters.Any()) |
||||
{ |
{ |
||||
case OpenFileDialog openFileDialog: |
chooserOptions.Add("filters", filters); |
||||
options.Add("multiple", openFileDialog.AllowMultiple); |
|
||||
objectPath = await _fileChooser.OpenFileAsync(parentWindow, openFileDialog.Title ?? string.Empty, options); |
|
||||
break; |
|
||||
case SaveFileDialog saveFileDialog: |
|
||||
if (saveFileDialog.InitialFileName is not null) |
|
||||
options.Add("current_name", saveFileDialog.InitialFileName); |
|
||||
if (saveFileDialog.Directory is not null) |
|
||||
options.Add("current_folder", Encoding.UTF8.GetBytes(saveFileDialog.Directory)); |
|
||||
objectPath = await _fileChooser.SaveFileAsync(parentWindow, saveFileDialog.Title ?? string.Empty, options); |
|
||||
break; |
|
||||
} |
} |
||||
|
|
||||
|
if (options.SuggestedFileName is { } currentName) |
||||
|
chooserOptions.Add("current_name", currentName); |
||||
|
if (options.SuggestedStartLocation?.TryGetUri(out var currentFolder) == true) |
||||
|
chooserOptions.Add("current_folder", Encoding.UTF8.GetBytes(currentFolder.ToString())); |
||||
|
objectPath = await _fileChooser.SaveFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions); |
||||
|
|
||||
var request = DBusHelper.Connection!.CreateProxy<IRequest>("org.freedesktop.portal.Request", objectPath); |
var request = DBusHelper.Connection!.CreateProxy<IRequest>("org.freedesktop.portal.Request", objectPath); |
||||
var tsc = new TaskCompletionSource<string[]?>(); |
var tsc = new TaskCompletionSource<string[]?>(); |
||||
using var disposable = await request.WatchResponseAsync(x => tsc.SetResult(x.results["uris"] as string[]), tsc.SetException); |
using var disposable = await request.WatchResponseAsync(x => tsc.SetResult(x.results["uris"] as string[]), tsc.SetException); |
||||
var uris = await tsc.Task; |
var uris = await tsc.Task; |
||||
if (uris is null) |
var path = uris?.FirstOrDefault() is { } filePath ? new Uri(filePath).AbsolutePath : null; |
||||
|
|
||||
|
if (path is null) |
||||
|
{ |
||||
return null; |
return null; |
||||
for (var i = 0; i < uris.Length; i++) |
} |
||||
uris[i] = new Uri(uris[i]).AbsolutePath; |
else |
||||
return uris; |
{ |
||||
|
// WSL2 freedesktop automatically adds extension from selected file type, but we can't pass "default ext". So apply it manually.
|
||||
|
path = StorageProviderHelpers.NameWithExtension(path, options.DefaultExtension, null); |
||||
|
|
||||
|
return new BclStorageFile(new FileInfo(path)); |
||||
|
} |
||||
} |
} |
||||
|
|
||||
public async Task<string?> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) |
public override async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options) |
||||
{ |
{ |
||||
var parentWindow = $"x11:{parent.PlatformImpl!.Handle.Handle.ToString("X")}"; |
var parentWindow = $"x11:{_handle.Handle:X}"; |
||||
var options = new Dictionary<string, object> |
var chooserOptions = new Dictionary<string, object> |
||||
{ |
{ |
||||
{ "directory", true } |
{ "directory", true }, |
||||
|
{ "multiple", options.AllowMultiple } |
||||
}; |
}; |
||||
var objectPath = await _fileChooser.OpenFileAsync(parentWindow, dialog.Title ?? string.Empty, options); |
var objectPath = await _fileChooser.OpenFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions); |
||||
var request = DBusHelper.Connection!.CreateProxy<IRequest>("org.freedesktop.portal.Request", objectPath); |
var request = DBusHelper.Connection!.CreateProxy<IRequest>("org.freedesktop.portal.Request", objectPath); |
||||
var tsc = new TaskCompletionSource<string[]?>(); |
var tsc = new TaskCompletionSource<string[]?>(); |
||||
using var disposable = await request.WatchResponseAsync(x => tsc.SetResult(x.results["uris"] as string[]), tsc.SetException); |
using var disposable = await request.WatchResponseAsync(x => tsc.SetResult(x.results["uris"] as string[]), tsc.SetException); |
||||
var uris = await tsc.Task; |
var uris = await tsc.Task ?? Array.Empty<string>(); |
||||
if (uris is null) |
|
||||
return null; |
return uris |
||||
return uris.Length != 1 ? string.Empty : new Uri(uris[0]).AbsolutePath; |
.Select(path => new Uri(path).AbsolutePath) |
||||
|
// WSL2 freedesktop allows to select files as well in directory picker, filter it out.
|
||||
|
.Where(Directory.Exists) |
||||
|
.Select(path => new BclStorageFolder(new DirectoryInfo(path))).ToList(); |
||||
} |
} |
||||
|
|
||||
private static (string name, (uint style, string extension)[])[] ParseFilters(FileDialog dialog) |
private static (string name, (uint style, string extension)[])[] ParseFilters(IReadOnlyList<FilePickerFileType>? fileTypes) |
||||
{ |
{ |
||||
var filters = new (string name, (uint style, string extension)[])[dialog.Filters!.Count]; |
// Example: [('Images', [(0, '*.ico'), (1, 'image/png')]), ('Text', [(0, '*.txt')])]
|
||||
for (var i = 0; i < filters.Length; i++) |
|
||||
|
if (fileTypes is null) |
||||
{ |
{ |
||||
var extensions = dialog.Filters[i].Extensions.Select(static x => (0u, x)).ToArray(); |
return Array.Empty<(string name, (uint style, string extension)[])>(); |
||||
filters[i] = (dialog.Filters[i].Name ?? string.Empty, extensions); |
} |
||||
|
|
||||
|
var filters = new List<(string name, (uint style, string extension)[])>(); |
||||
|
foreach (var fileType in fileTypes) |
||||
|
{ |
||||
|
const uint globStyle = 0u; |
||||
|
const uint mimeStyle = 1u; |
||||
|
|
||||
|
var extensions = Enumerable.Empty<(uint, string)>(); |
||||
|
|
||||
|
if (fileType.Patterns is { } patterns) |
||||
|
{ |
||||
|
extensions = extensions.Concat(patterns.Select(static x => (globStyle, x))); |
||||
|
} |
||||
|
else if (fileType.MimeTypes is { } mimeTypes) |
||||
|
{ |
||||
|
extensions = extensions.Concat(mimeTypes.Select(static x => (mimeStyle, x))); |
||||
|
} |
||||
|
|
||||
|
if (extensions.Any()) |
||||
|
{ |
||||
|
filters.Add((fileType.Name, extensions.ToArray())); |
||||
|
} |
||||
} |
} |
||||
|
|
||||
return filters; |
return filters.ToArray(); |
||||
} |
} |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,200 @@ |
|||||
|
using System.Diagnostics.CodeAnalysis; |
||||
|
|
||||
|
using Avalonia.Platform.Storage; |
||||
|
|
||||
|
using Microsoft.JSInterop; |
||||
|
|
||||
|
namespace Avalonia.Web.Blazor.Interop.Storage |
||||
|
{ |
||||
|
internal record FilePickerAcceptType(string Description, IReadOnlyDictionary<string, IReadOnlyList<string>> Accept); |
||||
|
|
||||
|
internal record FileProperties(ulong Size, long LastModified, string? Type); |
||||
|
|
||||
|
internal class StorageProviderInterop : JSModuleInterop, IStorageProvider |
||||
|
{ |
||||
|
private const string JsFilename = "./_content/Avalonia.Web.Blazor/StorageProvider.js"; |
||||
|
private const string PickerCancelMessage = "The user aborted a request"; |
||||
|
|
||||
|
public static async Task<StorageProviderInterop> ImportAsync(IJSRuntime js) |
||||
|
{ |
||||
|
var interop = new StorageProviderInterop(js); |
||||
|
await interop.ImportAsync(); |
||||
|
return interop; |
||||
|
} |
||||
|
|
||||
|
public StorageProviderInterop(IJSRuntime js) |
||||
|
: base(js, JsFilename) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
public bool CanOpen => Invoke<bool>("StorageProvider.canOpen"); |
||||
|
public bool CanSave => Invoke<bool>("StorageProvider.canSave"); |
||||
|
public bool CanPickFolder => Invoke<bool>("StorageProvider.canPickFolder"); |
||||
|
|
||||
|
public async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle; |
||||
|
|
||||
|
var (types, exludeAll) = ConvertFileTypes(options.FileTypeFilter); |
||||
|
var items = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.openFileDialog", startIn, options.AllowMultiple, types, exludeAll); |
||||
|
var count = items.Invoke<int>("count"); |
||||
|
|
||||
|
return Enumerable.Range(0, count) |
||||
|
.Select(index => new JSStorageFile(items.Invoke<IJSInProcessObjectReference>("at", index))) |
||||
|
.ToArray(); |
||||
|
} |
||||
|
catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal)) |
||||
|
{ |
||||
|
return Array.Empty<IStorageFile>(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle; |
||||
|
|
||||
|
var (types, exludeAll) = ConvertFileTypes(options.FileTypeChoices); |
||||
|
var item = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.saveFileDialog", startIn, options.SuggestedFileName, types, exludeAll); |
||||
|
|
||||
|
return item is not null ? new JSStorageFile(item) : null; |
||||
|
} |
||||
|
catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal)) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle; |
||||
|
|
||||
|
var item = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.selectFolderDialog", startIn); |
||||
|
|
||||
|
return item is not null ? new[] { new JSStorageFolder(item) } : Array.Empty<IStorageFolder>(); |
||||
|
} |
||||
|
catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal)) |
||||
|
{ |
||||
|
return Array.Empty<IStorageFolder>(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark) |
||||
|
{ |
||||
|
var item = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.openBookmark", bookmark); |
||||
|
return item is not null ? new JSStorageFile(item) : null; |
||||
|
} |
||||
|
|
||||
|
public async Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark) |
||||
|
{ |
||||
|
var item = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.openBookmark", bookmark); |
||||
|
return item is not null ? new JSStorageFolder(item) : null; |
||||
|
} |
||||
|
|
||||
|
private static (FilePickerAcceptType[]? types, bool excludeAllOption) ConvertFileTypes(IEnumerable<FilePickerFileType>? input) |
||||
|
{ |
||||
|
var types = input? |
||||
|
.Where(t => t.MimeTypes?.Any() == true && t != FilePickerFileTypes.All) |
||||
|
.Select(t => new FilePickerAcceptType(t.Name, t.MimeTypes! |
||||
|
.ToDictionary(m => m, _ => (IReadOnlyList<string>)Array.Empty<string>()))) |
||||
|
.ToArray(); |
||||
|
if (types?.Length == 0) |
||||
|
{ |
||||
|
types = null; |
||||
|
} |
||||
|
|
||||
|
var inlcudeAll = input?.Contains(FilePickerFileTypes.All) == true || types is null; |
||||
|
|
||||
|
return (types, !inlcudeAll); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
internal abstract class JSStorageItem : IStorageBookmarkItem |
||||
|
{ |
||||
|
internal IJSInProcessObjectReference? _fileHandle; |
||||
|
|
||||
|
protected JSStorageItem(IJSInProcessObjectReference fileHandle) |
||||
|
{ |
||||
|
_fileHandle = fileHandle ?? throw new ArgumentNullException(nameof(fileHandle)); |
||||
|
} |
||||
|
|
||||
|
internal IJSInProcessObjectReference FileHandle => _fileHandle ?? throw new ObjectDisposedException(nameof(JSStorageItem)); |
||||
|
|
||||
|
public string Name => FileHandle.Invoke<string>("getName"); |
||||
|
|
||||
|
public bool TryGetUri([NotNullWhen(true)] out Uri? uri) |
||||
|
{ |
||||
|
uri = new Uri(Name, UriKind.Relative); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
public async Task<StorageItemProperties> GetBasicPropertiesAsync() |
||||
|
{ |
||||
|
var properties = await FileHandle.InvokeAsync<FileProperties?>("getProperties"); |
||||
|
|
||||
|
return new StorageItemProperties( |
||||
|
properties?.Size, |
||||
|
dateCreated: null, |
||||
|
dateModified: properties?.LastModified > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(properties.LastModified) : null); |
||||
|
} |
||||
|
|
||||
|
public bool CanBookmark => true; |
||||
|
|
||||
|
public Task<string?> SaveBookmark() |
||||
|
{ |
||||
|
return FileHandle.InvokeAsync<string?>("saveBookmark").AsTask(); |
||||
|
} |
||||
|
|
||||
|
public Task<IStorageFolder?> GetParentAsync() |
||||
|
{ |
||||
|
return Task.FromResult<IStorageFolder?>(null); |
||||
|
} |
||||
|
|
||||
|
public Task ReleaseBookmark() |
||||
|
{ |
||||
|
return FileHandle.InvokeAsync<string?>("deleteBookmark").AsTask(); |
||||
|
} |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
_fileHandle?.Dispose(); |
||||
|
_fileHandle = null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
internal class JSStorageFile : JSStorageItem, IStorageBookmarkFile |
||||
|
{ |
||||
|
public JSStorageFile(IJSInProcessObjectReference fileHandle) : base(fileHandle) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
public bool CanOpenRead => true; |
||||
|
public async Task<Stream> OpenRead() |
||||
|
{ |
||||
|
var stream = await FileHandle.InvokeAsync<IJSStreamReference>("openRead"); |
||||
|
// Remove maxAllowedSize limit, as developer can decide if they read only small part or everything.
|
||||
|
return await stream.OpenReadStreamAsync(long.MaxValue, CancellationToken.None); |
||||
|
} |
||||
|
|
||||
|
public bool CanOpenWrite => true; |
||||
|
public async Task<Stream> OpenWrite() |
||||
|
{ |
||||
|
var properties = await FileHandle.InvokeAsync<FileProperties?>("getProperties"); |
||||
|
var streamWriter = await FileHandle.InvokeAsync<IJSInProcessObjectReference>("openWrite"); |
||||
|
|
||||
|
return new JSWriteableStream(streamWriter, (long)(properties?.Size ?? 0)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
internal class JSStorageFolder : JSStorageItem, IStorageBookmarkFolder |
||||
|
{ |
||||
|
public JSStorageFolder(IJSInProcessObjectReference fileHandle) : base(fileHandle) |
||||
|
{ |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,124 @@ |
|||||
|
using System.Buffers; |
||||
|
using System.Text.Json.Serialization; |
||||
|
|
||||
|
using Microsoft.JSInterop; |
||||
|
|
||||
|
namespace Avalonia.Web.Blazor.Interop.Storage |
||||
|
{ |
||||
|
// Loose wrapper implementaion of a stream on top of FileAPI FileSystemWritableFileStream
|
||||
|
internal sealed class JSWriteableStream : Stream |
||||
|
{ |
||||
|
private IJSInProcessObjectReference? _jSReference; |
||||
|
|
||||
|
// Unfortunatelly we can't read current length/position, so we need to keep it C#-side only.
|
||||
|
private long _length, _position; |
||||
|
|
||||
|
internal JSWriteableStream(IJSInProcessObjectReference jSReference, long initialLength) |
||||
|
{ |
||||
|
_jSReference = jSReference; |
||||
|
_length = initialLength; |
||||
|
} |
||||
|
|
||||
|
private IJSInProcessObjectReference JSReference => _jSReference ?? throw new ObjectDisposedException(nameof(JSWriteableStream)); |
||||
|
|
||||
|
public override bool CanRead => false; |
||||
|
|
||||
|
public override bool CanSeek => true; |
||||
|
|
||||
|
public override bool CanWrite => true; |
||||
|
|
||||
|
public override long Length => _length; |
||||
|
|
||||
|
public override long Position |
||||
|
{ |
||||
|
get => _position; |
||||
|
set => Seek(_position, SeekOrigin.Begin); |
||||
|
} |
||||
|
|
||||
|
public override void Flush() |
||||
|
{ |
||||
|
// no-op
|
||||
|
} |
||||
|
|
||||
|
public override int Read(byte[] buffer, int offset, int count) |
||||
|
{ |
||||
|
throw new NotSupportedException(); |
||||
|
} |
||||
|
|
||||
|
public override long Seek(long offset, SeekOrigin origin) |
||||
|
{ |
||||
|
var position = origin switch |
||||
|
{ |
||||
|
SeekOrigin.Current => _position + offset, |
||||
|
SeekOrigin.End => _length + offset, |
||||
|
_ => offset |
||||
|
}; |
||||
|
JSReference.InvokeVoid("seek", position); |
||||
|
return position; |
||||
|
} |
||||
|
|
||||
|
public override void SetLength(long value) |
||||
|
{ |
||||
|
_length = value; |
||||
|
|
||||
|
// See https://docs.w3cub.com/dom/filesystemwritablefilestream/truncate
|
||||
|
// If the offset is smaller than the size, it remains unchanged. If the offset is larger than size, the offset is set to that size
|
||||
|
if (_position > _length) |
||||
|
{ |
||||
|
_position = _length; |
||||
|
} |
||||
|
|
||||
|
JSReference.InvokeVoid("truncate", value); |
||||
|
} |
||||
|
|
||||
|
public override void Write(byte[] buffer, int offset, int count) |
||||
|
{ |
||||
|
throw new NotSupportedException("Synchronous writes are not supported."); |
||||
|
} |
||||
|
|
||||
|
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) |
||||
|
{ |
||||
|
if (offset != 0 || count != buffer.Length) |
||||
|
{ |
||||
|
// TODO, we need to pass prepared buffer to the JS
|
||||
|
// Can't use ArrayPool as it can return bigger array than requested
|
||||
|
// Can't use Span/Memory, as it's not supported by JS interop yet.
|
||||
|
// Alternatively we can pass original buffer and offset+count, so it can be trimmed on the JS side (but is it more efficient tho?)
|
||||
|
buffer = buffer.AsMemory(offset, count).ToArray(); |
||||
|
} |
||||
|
return WriteAsyncInternal(buffer, cancellationToken).AsTask(); |
||||
|
} |
||||
|
|
||||
|
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default) |
||||
|
{ |
||||
|
return WriteAsyncInternal(buffer.ToArray(), cancellationToken); |
||||
|
} |
||||
|
|
||||
|
private ValueTask WriteAsyncInternal(byte[] buffer, CancellationToken _) |
||||
|
{ |
||||
|
_position += buffer.Length; |
||||
|
|
||||
|
return JSReference.InvokeVoidAsync("write", buffer); |
||||
|
} |
||||
|
|
||||
|
protected override void Dispose(bool disposing) |
||||
|
{ |
||||
|
if (_jSReference is { } jsReference) |
||||
|
{ |
||||
|
_jSReference = null; |
||||
|
jsReference.InvokeVoid("close"); |
||||
|
jsReference.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public override async ValueTask DisposeAsync() |
||||
|
{ |
||||
|
if (_jSReference is { } jsReference) |
||||
|
{ |
||||
|
_jSReference = null; |
||||
|
await jsReference.InvokeVoidAsync("close"); |
||||
|
await jsReference.DisposeAsync(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,292 @@ |
|||||
|
// As we don't have proper package managing for Avalonia.Web project, declare types manually
|
||||
|
declare global { |
||||
|
interface FileSystemWritableFileStream { |
||||
|
write(position: number, data: BufferSource | Blob | string): Promise<void>; |
||||
|
truncate(size: number): Promise<void>; |
||||
|
close(): Promise<void>; |
||||
|
} |
||||
|
type PermissionsMode = "read" | "readwrite"; |
||||
|
interface FileSystemFileHandle { |
||||
|
name: string, |
||||
|
kind: "file" | "directory", |
||||
|
getFile(): Promise<File>; |
||||
|
createWritable(options?: { keepExistingData?: boolean }): Promise<FileSystemWritableFileStream>; |
||||
|
|
||||
|
queryPermission(options?: { mode: PermissionsMode }): Promise<"granted" | "denied" | "prompt">; |
||||
|
requestPermission(options?: { mode: PermissionsMode }): Promise<"granted" | "denied" | "prompt">; |
||||
|
} |
||||
|
type WellKnownDirectory = "desktop" | "documents" | "downloads" | "music" | "pictures" | "videos"; |
||||
|
type StartInDirectory = WellKnownDirectory | FileSystemFileHandle; |
||||
|
interface FilePickerAcceptType { |
||||
|
description: string, |
||||
|
// mime -> ext[] array
|
||||
|
accept: { [mime: string]: string | string[] } |
||||
|
} |
||||
|
interface FilePickerOptions { |
||||
|
types?: FilePickerAcceptType[], |
||||
|
excludeAcceptAllOption: boolean, |
||||
|
id?: string, |
||||
|
startIn?: StartInDirectory |
||||
|
} |
||||
|
interface OpenFilePickerOptions extends FilePickerOptions { |
||||
|
multiple: boolean |
||||
|
} |
||||
|
interface SaveFilePickerOptions extends FilePickerOptions { |
||||
|
suggestedName?: string |
||||
|
} |
||||
|
interface DirectoryPickerOptions { |
||||
|
id?: string, |
||||
|
startIn?: StartInDirectory |
||||
|
} |
||||
|
|
||||
|
interface Window { |
||||
|
showOpenFilePicker: (options: OpenFilePickerOptions) => Promise<FileSystemFileHandle[]>; |
||||
|
showSaveFilePicker: (options: SaveFilePickerOptions) => Promise<FileSystemFileHandle>; |
||||
|
showDirectoryPicker: (options: DirectoryPickerOptions) => Promise<FileSystemFileHandle>; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// TODO move to another file and use import
|
||||
|
class IndexedDbWrapper { |
||||
|
constructor(private databaseName: string, private objectStores: [ string ]) { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
public connect(): Promise<InnerDbConnection> { |
||||
|
var conn = window.indexedDB.open(this.databaseName, 1); |
||||
|
|
||||
|
conn.onupgradeneeded = event => { |
||||
|
const db = (<IDBRequest<IDBDatabase>>event.target).result; |
||||
|
this.objectStores.forEach(store => { |
||||
|
db.createObjectStore(store); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
return new Promise((resolve, reject) => { |
||||
|
conn.onsuccess = event => { |
||||
|
resolve(new InnerDbConnection((<IDBRequest<IDBDatabase>>event.target).result)); |
||||
|
} |
||||
|
conn.onerror = event => { |
||||
|
reject((<IDBRequest<IDBDatabase>>event.target).error); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class InnerDbConnection { |
||||
|
constructor(private database: IDBDatabase) { } |
||||
|
|
||||
|
private openStore(store: string, mode: IDBTransactionMode): IDBObjectStore { |
||||
|
const tx = this.database.transaction(store, mode); |
||||
|
return tx.objectStore(store); |
||||
|
} |
||||
|
|
||||
|
public put(store: string, obj: any, key?: IDBValidKey): Promise<IDBValidKey> { |
||||
|
const os = this.openStore(store, "readwrite"); |
||||
|
|
||||
|
return new Promise((resolve, reject) => { |
||||
|
var response = os.put(obj, key); |
||||
|
response.onsuccess = () => { |
||||
|
resolve(response.result); |
||||
|
}; |
||||
|
response.onerror = () => { |
||||
|
reject(response.error); |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public get(store: string, key: IDBValidKey): any { |
||||
|
const os = this.openStore(store, "readonly"); |
||||
|
|
||||
|
return new Promise((resolve, reject) => { |
||||
|
var response = os.get(key); |
||||
|
response.onsuccess = () => { |
||||
|
resolve(response.result); |
||||
|
}; |
||||
|
response.onerror = () => { |
||||
|
reject(response.error); |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public delete(store: string, key: IDBValidKey): Promise<void> { |
||||
|
const os = this.openStore(store, "readwrite"); |
||||
|
|
||||
|
return new Promise((resolve, reject) => { |
||||
|
var response = os.delete(key); |
||||
|
response.onsuccess = () => { |
||||
|
resolve(); |
||||
|
}; |
||||
|
response.onerror = () => { |
||||
|
reject(response.error); |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public close() { |
||||
|
this.database.close(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const fileBookmarksStore: string = "fileBookmarks"; |
||||
|
const avaloniaDb = new IndexedDbWrapper("AvaloniaDb", [ |
||||
|
fileBookmarksStore |
||||
|
]) |
||||
|
|
||||
|
class StorageItem { |
||||
|
constructor(private handle: FileSystemFileHandle, private bookmarkId?: string) { } |
||||
|
|
||||
|
public getName(): string { |
||||
|
return this.handle.name |
||||
|
} |
||||
|
|
||||
|
public async openRead(): Promise<Blob> { |
||||
|
await this.verityPermissions('read'); |
||||
|
|
||||
|
var file = await this.handle.getFile(); |
||||
|
return file; |
||||
|
} |
||||
|
|
||||
|
public async openWrite(): Promise<FileSystemWritableFileStream> { |
||||
|
await this.verityPermissions('readwrite'); |
||||
|
|
||||
|
return await this.handle.createWritable({ keepExistingData: true }); |
||||
|
} |
||||
|
|
||||
|
public async getProperties(): Promise<{ Size: number, LastModified: number, Type: string }> { |
||||
|
var file = this.handle.getFile && await this.handle.getFile(); |
||||
|
|
||||
|
return file && { |
||||
|
Size: file.size, |
||||
|
LastModified: file.lastModified, |
||||
|
Type: file.type |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async verityPermissions(mode: PermissionsMode): Promise<void | never> { |
||||
|
if (await this.handle.queryPermission({ mode }) === 'granted') { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (await this.handle.requestPermission({ mode }) === "denied") { |
||||
|
throw new Error("Read permissions denied"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async saveBookmark(): Promise<string> { |
||||
|
// If file was previously bookmarked, just return old one.
|
||||
|
if (this.bookmarkId) { |
||||
|
return this.bookmarkId; |
||||
|
} |
||||
|
|
||||
|
const connection = await avaloniaDb.connect(); |
||||
|
try { |
||||
|
const key = await connection.put(fileBookmarksStore, this.handle, this.generateBookmarkId()); |
||||
|
return <string>key; |
||||
|
} |
||||
|
finally { |
||||
|
connection.close(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async deleteBookmark(): Promise<void> { |
||||
|
if (!this.bookmarkId) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const connection = await avaloniaDb.connect(); |
||||
|
try { |
||||
|
const key = await connection.delete(fileBookmarksStore, this.bookmarkId); |
||||
|
} |
||||
|
finally { |
||||
|
connection.close(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private generateBookmarkId(): string { |
||||
|
return Date.now().toString(36) + Math.random().toString(36).substring(2); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class StorageItems { |
||||
|
constructor(private items: StorageItem[]) { } |
||||
|
|
||||
|
public count(): number { |
||||
|
return this.items.length; |
||||
|
} |
||||
|
|
||||
|
public at(index: number): StorageItem { |
||||
|
return this.items[index]; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export class StorageProvider { |
||||
|
|
||||
|
public static canOpen(): boolean { |
||||
|
return typeof window.showOpenFilePicker !== 'undefined'; |
||||
|
} |
||||
|
|
||||
|
public static canSave(): boolean { |
||||
|
return typeof window.showSaveFilePicker !== 'undefined'; |
||||
|
} |
||||
|
|
||||
|
public static canPickFolder(): boolean { |
||||
|
return typeof window.showDirectoryPicker !== 'undefined'; |
||||
|
} |
||||
|
|
||||
|
public static async selectFolderDialog( |
||||
|
startIn: StartInDirectory | null) |
||||
|
: Promise<StorageItem> { |
||||
|
|
||||
|
// 'Picker' API doesn't accept "null" as a parameter, so it should be set to undefined.
|
||||
|
const options: DirectoryPickerOptions = { |
||||
|
startIn: (startIn || undefined) |
||||
|
}; |
||||
|
|
||||
|
const handle = await window.showDirectoryPicker(options); |
||||
|
return new StorageItem(handle); |
||||
|
} |
||||
|
|
||||
|
public static async openFileDialog( |
||||
|
startIn: StartInDirectory | null, multiple: boolean, |
||||
|
types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean) |
||||
|
: Promise<StorageItems> { |
||||
|
|
||||
|
const options: OpenFilePickerOptions = { |
||||
|
startIn: (startIn || undefined), |
||||
|
multiple, |
||||
|
excludeAcceptAllOption, |
||||
|
types: (types || undefined) |
||||
|
}; |
||||
|
|
||||
|
const handles = await window.showOpenFilePicker(options); |
||||
|
return new StorageItems(handles.map(handle => new StorageItem(handle))); |
||||
|
} |
||||
|
|
||||
|
public static async saveFileDialog( |
||||
|
startIn: StartInDirectory | null, suggestedName: string | null, |
||||
|
types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean) |
||||
|
: Promise<StorageItem> { |
||||
|
|
||||
|
const options: SaveFilePickerOptions = { |
||||
|
startIn: (startIn || undefined), |
||||
|
suggestedName: (suggestedName || undefined), |
||||
|
excludeAcceptAllOption, |
||||
|
types: (types || undefined) |
||||
|
}; |
||||
|
|
||||
|
const handle = await window.showSaveFilePicker(options); |
||||
|
return new StorageItem(handle); |
||||
|
} |
||||
|
|
||||
|
public static async openBookmark(key: string): Promise<StorageItem | null> { |
||||
|
const connection = await avaloniaDb.connect(); |
||||
|
try { |
||||
|
const handle = await connection.get(fileBookmarksStore, key); |
||||
|
return handle && new StorageItem(handle, key); |
||||
|
} |
||||
|
finally { |
||||
|
connection.close(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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