Browse Source

IStorageProvider API updates

pull/9960/head
Max Katz 3 years ago
parent
commit
37545cbeb1
  1. 17
      samples/ControlCatalog/Pages/DialogsPage.xaml
  2. 59
      samples/ControlCatalog/Pages/DialogsPage.xaml.cs
  3. 9
      src/Android/Avalonia.Android/AvaloniaMainActivity.cs
  4. 3
      src/Android/Avalonia.Android/IActivityResultHandler.cs
  5. 52
      src/Android/Avalonia.Android/Platform/PlatformSupport.cs
  6. 101
      src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
  7. 124
      src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs
  8. 71
      src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs
  9. 63
      src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs
  10. 103
      src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs
  11. 18
      src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs
  12. 4
      src/Avalonia.Base/Platform/Storage/IStorageItem.cs
  13. 35
      src/Avalonia.Base/Platform/Storage/IStorageProvider.cs
  14. 6
      src/Avalonia.Base/Platform/Storage/NameCollisionOption.cs
  15. 55
      src/Avalonia.Base/Platform/Storage/StorageProviderExtensions.cs
  16. 37
      src/Avalonia.Base/Platform/Storage/WellKnownFolder.cs
  17. 11
      src/Avalonia.Controls/Platform/Dialogs/SystemDialogImpl.cs
  18. 5
      src/Avalonia.Dialogs/Avalonia.Dialogs.csproj
  19. 12
      src/Avalonia.Dialogs/Internal/ManagedFileChooserViewModel.cs
  20. 4
      src/Avalonia.Dialogs/ManagedFileDialogExtensions.cs
  21. 2
      src/Avalonia.Dialogs/ManagedStorageProvider.cs
  22. 6
      src/Avalonia.FreeDesktop/DBusSystemDialog.cs
  23. 9
      src/Avalonia.Native/SystemDialogs.cs
  24. 18
      src/Avalonia.X11/NativeDialogs/CompositeStorageProvider.cs
  25. 10
      src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs
  26. 3
      src/Browser/Avalonia.Browser/Interop/StorageHelper.cs
  27. 34
      src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs
  28. 34
      src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts
  29. 6
      src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts
  30. 4
      src/Windows/Avalonia.Win32/Win32StorageProvider.cs
  31. 41
      src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs
  32. 50
      src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs
  33. 15
      tests/Avalonia.Base.UnitTests/Utilities/UriExtensionsTests.cs

17
samples/ControlCatalog/Pages/DialogsPage.xaml

@ -1,6 +1,8 @@
<UserControl x:Class="ControlCatalog.Pages.DialogsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:storage="clr-namespace:Avalonia.Platform.Storage;assembly=Avalonia.Base"
xmlns:generic="clr-namespace:System.Collections.Generic;assembly=System.Collections">
<StackPanel Margin="4"
Orientation="Vertical"
Spacing="4">
@ -42,6 +44,19 @@
</StackPanel>
</Expander>
<AutoCompleteBox x:Name="CurrentFolderBox" Watermark="Write full path/uri or well known folder name">
<AutoCompleteBox.Items>
<generic:List x:TypeArguments="storage:WellKnownFolder">
<storage:WellKnownFolder>Desktop</storage:WellKnownFolder>
<storage:WellKnownFolder>Documents</storage:WellKnownFolder>
<storage:WellKnownFolder>Downloads</storage:WellKnownFolder>
<storage:WellKnownFolder>Pictures</storage:WellKnownFolder>
<storage:WellKnownFolder>Videos</storage:WellKnownFolder>
<storage:WellKnownFolder>Music</storage:WellKnownFolder>
</generic:List>
</AutoCompleteBox.Items>
</AutoCompleteBox>
<TextBlock x:Name="PickerLastResultsVisible"
Classes="h2"
IsVisible="False"

59
samples/ControlCatalog/Pages/DialogsPage.xaml.cs

@ -24,13 +24,38 @@ namespace ControlCatalog.Pages
{
this.InitializeComponent();
IStorageFolder? lastSelectedDirectory = null;
bool ignoreTextChanged = false;
var results = this.Get<ItemsPresenter>("PickerLastResults");
var resultsVisible = this.Get<TextBlock>("PickerLastResultsVisible");
var bookmarkContainer = this.Get<TextBox>("BookmarkContainer");
var openedFileContent = this.Get<TextBox>("OpenedFileContent");
var openMultiple = this.Get<CheckBox>("OpenMultiple");
var currentFolderBox = this.Get<AutoCompleteBox>("CurrentFolderBox");
currentFolderBox.TextChanged += async (sender, args) =>
{
if (ignoreTextChanged) return;
if (Enum.TryParse<WellKnownFolder>(currentFolderBox.Text, true, out var folderEnum))
{
lastSelectedDirectory = await GetStorageProvider().TryGetWellKnownFolder(folderEnum);
}
else
{
if (!Uri.TryCreate(currentFolderBox.Text, UriKind.Absolute, out var folderLink))
{
Uri.TryCreate("file://" + currentFolderBox.Text, UriKind.Absolute, out folderLink);
}
if (folderLink is not null)
{
lastSelectedDirectory = await GetStorageProvider().TryGetFolderFromPath(folderLink);
}
}
};
IStorageFolder? lastSelectedDirectory = null;
List<FileDialogFilter> GetFilters()
{
@ -84,7 +109,7 @@ namespace ControlCatalog.Pages
{
Title = "Open multiple files",
Filters = GetFilters(),
Directory = lastSelectedDirectory?.TryGetUri(out var path) == true ? path.LocalPath : null,
Directory = lastSelectedDirectory?.Path is {IsAbsoluteUri:true} path ? path.LocalPath : null,
AllowMultiple = true
}.ShowAsync(GetWindow());
results.Items = result;
@ -97,7 +122,7 @@ namespace ControlCatalog.Pages
{
Title = "Save file",
Filters = filters,
Directory = lastSelectedDirectory?.TryGetUri(out var path) == true ? path.LocalPath : null,
Directory = lastSelectedDirectory?.Path is {IsAbsoluteUri:true} path ? path.LocalPath : null,
DefaultExtension = filters?.Any() == true ? "txt" : null,
InitialFileName = "test.txt"
}.ShowAsync(GetWindow());
@ -109,7 +134,7 @@ namespace ControlCatalog.Pages
var result = await new OpenFolderDialog()
{
Title = "Select folder",
Directory = lastSelectedDirectory?.TryGetUri(out var path) == true ? path.LocalPath : null
Directory = lastSelectedDirectory?.Path is {IsAbsoluteUri:true} path ? path.LocalPath : null,
}.ShowAsync(GetWindow());
if (string.IsNullOrEmpty(result))
{
@ -117,7 +142,7 @@ namespace ControlCatalog.Pages
}
else
{
lastSelectedDirectory = new BclStorageFolder(new System.IO.DirectoryInfo(result));
SetFolder(await GetStorageProvider().TryGetFolderFromPath(result));
results.Items = new[] { result };
resultsVisible.IsVisible = true;
}
@ -127,7 +152,7 @@ namespace ControlCatalog.Pages
var result = await new OpenFileDialog()
{
Title = "Select both",
Directory = lastSelectedDirectory?.TryGetUri(out var path) == true ? path.LocalPath : null,
Directory = lastSelectedDirectory?.Path is {IsAbsoluteUri:true} path ? path.LocalPath : null,
AllowMultiple = true
}.ShowManagedAsync(GetWindow(), new ManagedFileDialogOptions
{
@ -210,7 +235,7 @@ namespace ControlCatalog.Pages
#endif
await reader.WriteLineAsync(openedFileContent.Text);
lastSelectedDirectory = await file.GetParentAsync();
SetFolder(await file.GetParentAsync());
}
await SetPickerResult(file is null ? null : new [] {file});
@ -226,7 +251,7 @@ namespace ControlCatalog.Pages
await SetPickerResult(folders);
lastSelectedDirectory = folders.FirstOrDefault();
SetFolder(folders.FirstOrDefault());
};
this.Get<Button>("OpenFileFromBookmark").Click += async delegate
{
@ -244,9 +269,16 @@ namespace ControlCatalog.Pages
await SetPickerResult(folder is null ? null : new[] { folder });
lastSelectedDirectory = folder;
SetFolder(folder);
};
void SetFolder(IStorageFolder? folder)
{
ignoreTextChanged = true;
lastSelectedDirectory = folder;
currentFolderBox.Text = folder?.Path.LocalPath;
ignoreTextChanged = false;
}
async Task SetPickerResult(IReadOnlyCollection<IStorageItem>? items)
{
items ??= Array.Empty<IStorageItem>();
@ -297,10 +329,11 @@ Content:
openedFileContent.Text = resultText;
lastSelectedDirectory = await item.GetParentAsync();
if (lastSelectedDirectory is not null)
var parent = await item.GetParentAsync();
SetFolder(parent);
if (parent is not null)
{
mappedResults.Add(FullPathOrName(lastSelectedDirectory));
mappedResults.Add(FullPathOrName(parent));
}
foreach (var selectedItem in items)
@ -393,7 +426,7 @@ CanPickFolder: {storageProvider.CanPickFolder}";
private static string FullPathOrName(IStorageItem? item)
{
if (item is null) return "(null)";
return item.TryGetUri(out var uri) ? uri.ToString() : item.Name;
return item.Path is { IsAbsoluteUri: true } path ? path.ToString() : item.Name;
}
Window GetWindow() => this.VisualRoot as Window ?? throw new NullReferenceException("Invalid Owner");

9
src/Android/Avalonia.Android/AvaloniaMainActivity.cs

@ -1,6 +1,7 @@
using System;
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.Content.Res;
using Android.OS;
using Android.Runtime;
@ -17,6 +18,7 @@ namespace Avalonia.Android
internal static object ViewContent;
public Action<int, Result, Intent> ActivityResult { get; set; }
public Action<int, string[], Permission[]> RequestPermissionsResult { get; set; }
internal AvaloniaView View;
private GlobalLayoutListener _listener;
@ -77,6 +79,13 @@ namespace Avalonia.Android
ActivityResult?.Invoke(requestCode, resultCode, data);
}
public override void OnRequestPermissionsResult(int requestCode, string[] permissions, Permission[] grantResults)
{
base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
RequestPermissionsResult?.Invoke(requestCode, permissions, grantResults);
}
class GlobalLayoutListener : Java.Lang.Object, ViewTreeObserver.IOnGlobalLayoutListener
{
private AvaloniaView _view;

3
src/Android/Avalonia.Android/IActivityResultHandler.cs

@ -1,11 +1,14 @@
using System;
using Android.App;
using Android.Content;
using Android.Content.PM;
namespace Avalonia.Android
{
public interface IActivityResultHandler
{
public Action<int, Result, Intent> ActivityResult { get; set; }
public Action<int, string[], Permission[]> RequestPermissionsResult { get; set; }
}
}

52
src/Android/Avalonia.Android/Platform/PlatformSupport.cs

@ -0,0 +1,52 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Android.App;
using Android.Content;
using Android.Content.PM;
namespace Avalonia.Android.Platform;
internal static class PlatformSupport
{
private static int s_lastRequestCode = 20000;
public static int GetNextRequestCode() => s_lastRequestCode++;
public static async Task<bool> CheckPermission(this Activity activity, string permission)
{
if (activity is not IActivityResultHandler mainActivity)
{
throw new InvalidOperationException("Main activity must implement IActivityResultHandler interface.");
}
if (!OperatingSystem.IsAndroidVersionAtLeast(23))
{
return true;
}
if (activity.CheckSelfPermission(permission) == Permission.Granted)
{
return true;
}
var currentRequestCode = GetNextRequestCode();
var tcs = new TaskCompletionSource<bool>();
mainActivity.RequestPermissionsResult += RequestPermissionsResult;
activity.RequestPermissions(new [] { permission }, currentRequestCode);
return await tcs.Task;
void RequestPermissionsResult(int requestCode, string[] arg2, Permission[] arg3)
{
if (currentRequestCode != requestCode)
{
return;
}
mainActivity.RequestPermissionsResult -= RequestPermissionsResult;
_ = tcs.TrySetResult(arg3.All(p => p == Permission.Granted));
}
}
}

101
src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs

@ -2,10 +2,11 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Android;
using Android.App;
using Android.Content;
using Android.Provider;
using Avalonia.Logging;
@ -19,41 +20,48 @@ namespace Avalonia.Android.Platform.Storage;
internal abstract class AndroidStorageItem : IStorageBookmarkItem
{
private Context? _context;
private Activity? _activity;
private readonly bool _needsExternalFilesPermission;
protected AndroidStorageItem(Context context, AndroidUri uri)
protected AndroidStorageItem(Activity activity, AndroidUri uri, bool needsExternalFilesPermission)
{
_context = context;
_activity = activity;
_needsExternalFilesPermission = needsExternalFilesPermission;
Uri = uri;
}
internal AndroidUri Uri { get; }
protected Activity Activity => _activity ?? throw new ObjectDisposedException(nameof(AndroidStorageItem));
protected Context Context => _context ?? throw new ObjectDisposedException(nameof(AndroidStorageItem));
public string Name => GetColumnValue(Context, Uri, MediaStore.IMediaColumns.DisplayName)
public virtual string Name => GetColumnValue(Activity, Uri, MediaStore.IMediaColumns.DisplayName)
?? Uri.PathSegments?.LastOrDefault() ?? string.Empty;
public Uri Path => new(Uri.ToString()!);
public bool CanBookmark => true;
public Task<string?> SaveBookmarkAsync()
public async Task<string?> SaveBookmarkAsync()
{
Context.ContentResolver?.TakePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission);
return Task.FromResult(Uri.ToString());
}
if (!await EnsureExternalFilesPermission(false))
{
return null;
}
public Task ReleaseBookmarkAsync()
{
Context.ContentResolver?.ReleasePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission);
return Task.CompletedTask;
Activity.ContentResolver?.TakePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission);
return Uri.ToString();
}
public bool TryGetUri([NotNullWhen(true)] out Uri? uri)
public async Task ReleaseBookmarkAsync()
{
uri = new Uri(Uri.ToString()!);
return true;
}
if (!await EnsureExternalFilesPermission(false))
{
return;
}
Activity.ContentResolver?.ReleasePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission);
}
public abstract Task<StorageItemProperties> GetBasicPropertiesAsync();
protected string? GetColumnValue(Context context, AndroidUri contentUri, string column, string? selection = null, string[]? selectionArgs = null)
@ -77,29 +85,44 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
return null;
}
public Task<IStorageFolder?> GetParentAsync()
public async Task<IStorageFolder?> GetParentAsync()
{
if (!await EnsureExternalFilesPermission(false))
{
return null;
}
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 new AndroidStorageFolder(Activity, androidUri, false);
}
return Task.FromResult<IStorageFolder?>(null);
return null;
}
protected async Task<bool> EnsureExternalFilesPermission(bool write)
{
if (!_needsExternalFilesPermission)
{
return true;
}
return await _activity.CheckPermission(Manifest.Permission.ReadExternalStorage);
}
public void Dispose()
{
_context = null;
_activity = null;
}
}
internal sealed class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
{
public AndroidStorageFolder(Context context, AndroidUri uri) : base(context, uri)
public AndroidStorageFolder(Activity activity, AndroidUri uri, bool needsExternalFilesPermission) : base(activity, uri, needsExternalFilesPermission)
{
}
@ -110,6 +133,11 @@ internal sealed class AndroidStorageFolder : AndroidStorageItem, IStorageBookmar
public async Task<IReadOnlyList<IStorageItem>> GetItemsAsync()
{
if (!await EnsureExternalFilesPermission(false))
{
return Array.Empty<IStorageItem>();
}
using var javaFile = new JavaFile(Uri.Path!);
// Java file represents files AND directories. Don't be confused.
@ -124,8 +152,8 @@ internal sealed class AndroidStorageFolder : AndroidStorageItem, IStorageBookmar
.Where(t => t.uri is not null)
.Select(t => t.file switch
{
{ IsFile: true } => (IStorageItem)new AndroidStorageFile(Context, t.uri!),
{ IsDirectory: true } => new AndroidStorageFolder(Context, t.uri!),
{ IsFile: true } => (IStorageItem)new AndroidStorageFile(Activity, t.uri!),
{ IsDirectory: true } => new AndroidStorageFolder(Activity, t.uri!, false),
_ => null
})
.Where(i => i is not null)
@ -133,9 +161,20 @@ internal sealed class AndroidStorageFolder : AndroidStorageItem, IStorageBookmar
}
}
internal sealed class WellKnownAndroidStorageFolder : AndroidStorageFolder
{
public WellKnownAndroidStorageFolder(Activity activity, string identifier, AndroidUri uri, bool needsExternalFilesPermission)
: base(activity, uri, needsExternalFilesPermission)
{
Name = identifier;
}
public override string Name { get; }
}
internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkFile
{
public AndroidStorageFile(Context context, AndroidUri uri) : base(context, uri)
public AndroidStorageFile(Activity activity, AndroidUri uri) : base(activity, uri, false)
{
}
@ -143,10 +182,10 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF
public bool CanOpenWrite => true;
public Task<Stream> OpenReadAsync() => Task.FromResult(OpenContentStream(Context, Uri, false)
public Task<Stream> OpenReadAsync() => Task.FromResult(OpenContentStream(Activity, Uri, false)
?? throw new InvalidOperationException("Failed to open content stream"));
public Task<Stream> OpenWriteAsync() => Task.FromResult(OpenContentStream(Context, Uri, true)
public Task<Stream> OpenWriteAsync() => Task.FromResult(OpenContentStream(Activity, Uri, true)
?? throw new InvalidOperationException("Failed to open content stream"));
private Stream? OpenContentStream(Context context, AndroidUri uri, bool isOutput)
@ -210,7 +249,7 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF
MediaStore.IMediaColumns.Size, MediaStore.IMediaColumns.DateAdded,
MediaStore.IMediaColumns.DateModified
};
using var cursor = Context.ContentResolver!.Query(Uri, projection, null, null, null);
using var cursor = Activity.ContentResolver!.Query(Uri, projection, null, null, null);
if (cursor?.MoveToFirst() == true)
{

124
src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs

@ -4,18 +4,21 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Android;
using Android.App;
using Android.Content;
using Android.Provider;
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 class AndroidStorageProvider : IStorageProvider
{
private readonly Activity _activity;
private int _lastRequestCode = 20000;
public AndroidStorageProvider(Activity activity)
{
@ -31,7 +34,108 @@ internal class AndroidStorageProvider : IStorageProvider
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));
return Task.FromResult<IStorageBookmarkFolder?>(new AndroidStorageFolder(_activity, uri, false));
}
public async Task<IStorageFile?> TryGetFileFromPath(Uri filePath)
{
if (filePath is null)
{
throw new ArgumentNullException(nameof(filePath));
}
if (filePath is not { IsAbsoluteUri: true, Scheme: "file" or "content" })
{
throw new ArgumentException("File path is expected to be an absolute link with \"file\" or \"content\" scheme.");
}
var androidUri = AndroidUri.Parse(filePath.ToString());
if (androidUri?.Path is not {} androidUriPath)
{
return null;
}
var hasPerms = await _activity.CheckPermission(Manifest.Permission.ReadExternalStorage);
if (!hasPerms)
{
throw new SecurityException("Application doesn't have ReadExternalStorage permission. Make sure android manifest has this permission defined and user allowed it.");
}
var javaFile = new JavaFile(androidUriPath);
if (javaFile.Exists() && javaFile.IsFile)
{
return null;
}
return new AndroidStorageFile(_activity, androidUri);
}
public async Task<IStorageFolder?> TryGetFolderFromPath(Uri folderPath)
{
if (folderPath is null)
{
throw new ArgumentNullException(nameof(folderPath));
}
if (folderPath is not { IsAbsoluteUri: true, Scheme: "file" or "content" })
{
throw new ArgumentException("Folder path is expected to be an absolute link with \"file\" or \"content\" scheme.");
}
var androidUri = AndroidUri.Parse(folderPath.ToString());
if (androidUri?.Path is not {} androidUriPath)
{
return null;
}
var hasPerms = await _activity.CheckPermission(Manifest.Permission.ReadExternalStorage);
if (!hasPerms)
{
throw new SecurityException("Application doesn't have ReadExternalStorage permission. Make sure android manifest has this permission defined and user allowed it.");
}
var javaFile = new JavaFile(androidUriPath);
if (javaFile.Exists() && javaFile.IsDirectory)
{
return null;
}
return new AndroidStorageFolder(_activity, androidUri, false);
}
public Task<IStorageFolder?> TryGetWellKnownFolder(WellKnownFolder wellKnownFolder)
{
var dirCode = wellKnownFolder switch
{
WellKnownFolder.Desktop => null,
WellKnownFolder.Documents => global::Android.OS.Environment.DirectoryDocuments,
WellKnownFolder.Downloads => global::Android.OS.Environment.DirectoryDownloads,
WellKnownFolder.Music => global::Android.OS.Environment.DirectoryMusic,
WellKnownFolder.Pictures => global::Android.OS.Environment.DirectoryPictures,
WellKnownFolder.Videos => global::Android.OS.Environment.DirectoryMovies,
_ => throw new ArgumentOutOfRangeException(nameof(wellKnownFolder), wellKnownFolder, null)
};
if (dirCode is null)
{
return Task.FromResult<IStorageFolder?>(null);
}
var dir = _activity.GetExternalFilesDir(dirCode);
if (dir is null || !dir.Exists())
{
return Task.FromResult<IStorageFolder?>(null);
}
var uri = AndroidUri.FromFile(dir);
if (uri is null)
{
return Task.FromResult<IStorageFolder?>(null);
}
// To make TryGetWellKnownFolder API easier to use, we don't check for the permissions.
// It will work with file picker activities, but it will fail on any direct access to the folder, like getting list of children.
// We pass "needsExternalFilesPermission" parameter here, so folder itself can check for permissions on any FS access.
return Task.FromResult<IStorageFolder?>(new WellKnownAndroidStorageFolder(_activity, dirCode, uri, true));
}
public Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark)
@ -110,19 +214,21 @@ internal class AndroidStorageProvider : IStorageProvider
var pickerIntent = Intent.CreateChooser(intent, options.Title ?? "Select folder");
var uris = await StartActivity(pickerIntent, false);
return uris.Select(u => new AndroidStorageFolder(_activity, u)).ToArray();
return uris.Select(u => new AndroidStorageFolder(_activity, u, false)).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++;
var currentRequestCode = PlatformSupport.GetNextRequestCode();
if (_activity is IActivityResultHandler mainActivity)
if (!(_activity is IActivityResultHandler mainActivity))
{
mainActivity.ActivityResult += OnActivityResult;
throw new InvalidOperationException("Main activity must implement IActivityResultHandler interface.");
}
mainActivity.ActivityResult += OnActivityResult;
_activity.StartActivityForResult(pickerIntent, currentRequestCode);
var result = await tcs.Task;
@ -161,11 +267,7 @@ internal class AndroidStorageProvider : IStorageProvider
return;
}
if (_activity is IActivityResultHandler mainActivity)
{
mainActivity.ActivityResult -= OnActivityResult;
}
mainActivity.ActivityResult -= OnActivityResult;
_ = tcs.TrySetResult(resultCode == Result.Ok ? data : null);
}

71
src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs

@ -1,50 +1,65 @@
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
internal class BclStorageFile : IStorageBookmarkFile
{
private readonly FileInfo _fileInfo;
public BclStorageFile(string fileName)
{
_fileInfo = new FileInfo(fileName);
FileInfo = new FileInfo(fileName);
}
public BclStorageFile(FileInfo fileInfo)
{
_fileInfo = fileInfo ?? throw new ArgumentNullException(nameof(fileInfo));
FileInfo = fileInfo ?? throw new ArgumentNullException(nameof(fileInfo));
}
public FileInfo FileInfo { get; }
public bool CanOpenRead => true;
public bool CanOpenWrite => true;
public string Name => _fileInfo.Name;
public string Name => FileInfo.Name;
public virtual bool CanBookmark => true;
public Uri Path
{
get
{
try
{
if (FileInfo.Directory is not null)
{
return StorageProviderHelpers.FilePathToUri(FileInfo.FullName);
}
}
catch (SecurityException)
{
}
return new Uri(FileInfo.Name, UriKind.Relative);
}
}
public Task<StorageItemProperties> GetBasicPropertiesAsync()
{
if (_fileInfo.Exists)
if (FileInfo.Exists)
{
return Task.FromResult(new StorageItemProperties(
(ulong)_fileInfo.Length,
_fileInfo.CreationTimeUtc,
_fileInfo.LastAccessTimeUtc));
(ulong)FileInfo.Length,
FileInfo.CreationTimeUtc,
FileInfo.LastAccessTimeUtc));
}
return Task.FromResult(new StorageItemProperties());
}
public Task<IStorageFolder?> GetParentAsync()
{
if (_fileInfo.Directory is { } directory)
if (FileInfo.Directory is { } directory)
{
return Task.FromResult<IStorageFolder?>(new BclStorageFolder(directory));
}
@ -53,17 +68,17 @@ public class BclStorageFile : IStorageBookmarkFile
public Task<Stream> OpenReadAsync()
{
return Task.FromResult<Stream>(_fileInfo.OpenRead());
return Task.FromResult<Stream>(FileInfo.OpenRead());
}
public Task<Stream> OpenWriteAsync()
{
return Task.FromResult<Stream>(_fileInfo.OpenWrite());
return Task.FromResult<Stream>(FileInfo.OpenWrite());
}
public virtual Task<string?> SaveBookmarkAsync()
{
return Task.FromResult<string?>(_fileInfo.FullName);
return Task.FromResult<string?>(FileInfo.FullName);
}
public Task ReleaseBookmarkAsync()
@ -72,28 +87,6 @@ public class BclStorageFile : IStorageBookmarkFile
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)
{
}

63
src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs

@ -1,23 +1,18 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Security;
using System.Threading.Tasks;
using Avalonia.Metadata;
namespace Avalonia.Platform.Storage.FileIO;
[Unstable]
public class BclStorageFolder : IStorageBookmarkFolder
internal class BclStorageFolder : IStorageBookmarkFolder
{
private readonly DirectoryInfo _directoryInfo;
public BclStorageFolder(string path)
{
_directoryInfo = new DirectoryInfo(path);
if (!_directoryInfo.Exists)
DirectoryInfo = new DirectoryInfo(path);
if (!DirectoryInfo.Exists)
{
throw new ArgumentException("Directory must exist");
}
@ -25,29 +20,46 @@ public class BclStorageFolder : IStorageBookmarkFolder
public BclStorageFolder(DirectoryInfo directoryInfo)
{
_directoryInfo = directoryInfo ?? throw new ArgumentNullException(nameof(directoryInfo));
if (!_directoryInfo.Exists)
DirectoryInfo = directoryInfo ?? throw new ArgumentNullException(nameof(directoryInfo));
if (!DirectoryInfo.Exists)
{
throw new ArgumentException("Directory must exist", nameof(directoryInfo));
}
}
public string Name => _directoryInfo.Name;
public string Name => DirectoryInfo.Name;
public DirectoryInfo DirectoryInfo { get; }
public bool CanBookmark => true;
public Uri Path
{
get
{
try
{
return StorageProviderHelpers.FilePathToUri(DirectoryInfo.FullName);
}
catch (SecurityException)
{
return new Uri(DirectoryInfo.Name, UriKind.Relative);
}
}
}
public Task<StorageItemProperties> GetBasicPropertiesAsync()
{
var props = new StorageItemProperties(
null,
_directoryInfo.CreationTimeUtc,
_directoryInfo.LastAccessTimeUtc);
DirectoryInfo.CreationTimeUtc,
DirectoryInfo.LastAccessTimeUtc);
return Task.FromResult(props);
}
public Task<IStorageFolder?> GetParentAsync()
{
if (_directoryInfo.Parent is { } directory)
if (DirectoryInfo.Parent is { } directory)
{
return Task.FromResult<IStorageFolder?>(new BclStorageFolder(directory));
}
@ -56,9 +68,9 @@ public class BclStorageFolder : IStorageBookmarkFolder
public Task<IReadOnlyList<IStorageItem>> GetItemsAsync()
{
var items = _directoryInfo.GetDirectories()
var items = DirectoryInfo.GetDirectories()
.Select(d => (IStorageItem)new BclStorageFolder(d))
.Concat(_directoryInfo.GetFiles().Select(f => new BclStorageFile(f)))
.Concat(DirectoryInfo.GetFiles().Select(f => new BclStorageFile(f)))
.ToArray();
return Task.FromResult<IReadOnlyList<IStorageItem>>(items);
@ -66,7 +78,7 @@ public class BclStorageFolder : IStorageBookmarkFolder
public virtual Task<string?> SaveBookmarkAsync()
{
return Task.FromResult<string?>(_directoryInfo.FullName);
return Task.FromResult<string?>(DirectoryInfo.FullName);
}
public Task ReleaseBookmarkAsync()
@ -75,23 +87,6 @@ public class BclStorageFolder : IStorageBookmarkFolder
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)
{
}

103
src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs

@ -1,12 +1,13 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Avalonia.Metadata;
using Avalonia.Compatibility;
namespace Avalonia.Platform.Storage.FileIO;
[Unstable]
public abstract class BclStorageProvider : IStorageProvider
internal abstract class BclStorageProvider : IStorageProvider
{
public abstract bool CanOpen { get; }
public abstract Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options);
@ -32,4 +33,98 @@ public abstract class BclStorageProvider : IStorageProvider
? Task.FromResult<IStorageBookmarkFolder?>(new BclStorageFolder(folder))
: Task.FromResult<IStorageBookmarkFolder?>(null);
}
public virtual Task<IStorageFile?> TryGetFileFromPath(Uri filePath)
{
if (filePath.IsAbsoluteUri)
{
var file = new FileInfo(filePath.LocalPath);
if (file.Exists)
{
return Task.FromResult<IStorageFile?>(new BclStorageFile(file));
}
}
return Task.FromResult<IStorageFile?>(null);
}
public virtual Task<IStorageFolder?> TryGetFolderFromPath(Uri folderPath)
{
if (folderPath.IsAbsoluteUri)
{
var directory = new DirectoryInfo(folderPath.LocalPath);
if (directory.Exists)
{
return Task.FromResult<IStorageFolder?>(new BclStorageFolder(directory));
}
}
return Task.FromResult<IStorageFolder?>(null);
}
public virtual Task<IStorageFolder?> TryGetWellKnownFolder(WellKnownFolder wellKnownFolder)
{
// Note, this BCL API returns different values depending on the .NET version.
// We should also document it.
// https://github.com/dotnet/docs/issues/31423
// For pre-breaking change values see table:
// https://johnkoerner.com/csharp/special-folder-values-on-windows-versus-mac/
var folderPath = wellKnownFolder switch
{
WellKnownFolder.Desktop => GetFromSpecialFolder(Environment.SpecialFolder.Desktop),
WellKnownFolder.Documents => GetFromSpecialFolder(Environment.SpecialFolder.MyDocuments),
WellKnownFolder.Downloads => GetDownloadsWellKnownFolder(),
WellKnownFolder.Music => GetFromSpecialFolder(Environment.SpecialFolder.MyMusic),
WellKnownFolder.Pictures => GetFromSpecialFolder(Environment.SpecialFolder.MyPictures),
WellKnownFolder.Videos => GetFromSpecialFolder(Environment.SpecialFolder.MyVideos),
_ => throw new ArgumentOutOfRangeException(nameof(wellKnownFolder), wellKnownFolder, null)
};
if (folderPath is null)
{
return Task.FromResult<IStorageFolder?>(null);
}
var directory = new DirectoryInfo(folderPath);
if (!directory.Exists)
{
return Task.FromResult<IStorageFolder?>(null);
}
return Task.FromResult<IStorageFolder?>(new BclStorageFolder(directory));
string GetFromSpecialFolder(Environment.SpecialFolder folder) =>
Environment.GetFolderPath(folder, Environment.SpecialFolderOption.Create);
}
// TODO, replace with https://github.com/dotnet/runtime/issues/70484 when implemented.
// Normally we want to avoid platform specific code in the Avalonia.Base assembly.
protected static string? GetDownloadsWellKnownFolder()
{
if (OperatingSystemEx.IsWindows())
{
return Environment.OSVersion.Version.Major < 6 ? null :
SHGetKnownFolderPath(s_folderDownloads, 0, IntPtr.Zero);
}
if (OperatingSystemEx.IsLinux())
{
var envDir = Environment.GetEnvironmentVariable("XDG_DOWNLOAD_DIR");
if (envDir != null && Directory.Exists(envDir))
{
return envDir;
}
}
if (OperatingSystemEx.IsLinux() || OperatingSystemEx.IsMacOS())
{
return "~/Downloads";
}
return null;
}
private static readonly Guid s_folderDownloads = new Guid("374DE290-123F-4565-9164-39C4925E467B");
[DllImport("shell32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, PreserveSig = false)]
private static extern string SHGetKnownFolderPath([MarshalAs(UnmanagedType.LPStruct)] Guid id, int flags, IntPtr token);
}

18
src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs

@ -1,13 +1,25 @@
using System;
using System.IO;
using System.Linq;
using Avalonia.Metadata;
using System.Text;
namespace Avalonia.Platform.Storage.FileIO;
[Unstable]
public static class StorageProviderHelpers
internal static class StorageProviderHelpers
{
public static Uri FilePathToUri(string path)
{
var uriPath = new StringBuilder(path)
.Replace("%", $"%{(int)'%':X2}")
.Replace("[", $"%{(int)'[':X2}")
.Replace("]", $"%{(int)']':X2}")
.ToString();
return Path.IsPathRooted(path) ?
new UriBuilder("file", string.Empty) { Path = uriPath }.Uri :
new Uri(uriPath, UriKind.Relative);
}
public static string NameWithExtension(string path, string? defaultExtension, FilePickerFileType? filter)
{
var name = Path.GetFileName(path);

4
src/Avalonia.Base/Platform/Storage/IStorageItem.cs

@ -20,13 +20,13 @@ public interface IStorageItem : IDisposable
string Name { get; }
/// <summary>
/// Gets the full file-system path of the item, if the item has a path.
/// Gets the file-system path of the item.
/// </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);
Uri Path { get; }
/// <summary>
/// Gets the basic properties of the current item.

35
src/Avalonia.Base/Platform/Storage/IStorageProvider.cs

@ -1,4 +1,6 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Runtime.Versioning;
using System.Threading.Tasks;
using Avalonia.Metadata;
@ -53,4 +55,35 @@ public interface IStorageProvider
/// <param name="bookmark">Bookmark ID.</param>
/// <returns>Bookmarked folder or null if OS denied request.</returns>
Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark);
/// <summary>
/// Attempts to read file from the file-system by its path.
/// </summary>
/// <param name="filePath">The path of the item to retrieve in Uri format.</param>
/// <remarks>
/// Uri path is usually expected to be an absolute path with "file" scheme.
/// But it can be an uri with "content" scheme on the Android.
/// It also might ask user for the permission, and throw an exception if it was denied.
/// </remarks>
/// <returns>File or null if it doesn't exist.</returns>
Task<IStorageFile?> TryGetFileFromPath(Uri filePath);
/// <summary>
/// Attempts to read folder from the file-system by its path.
/// </summary>
/// <param name="folderPath">The path of the folder to retrieve in Uri format.</param>
/// <remarks>
/// Uri path is usually expected to be an absolute path with "file" scheme.
/// But it can be an uri with "content" scheme on the Android.
/// It also might ask user for the permission, and throw an exception if it was denied.
/// </remarks>
/// <returns>Folder or null if it doesn't exist.</returns>
Task<IStorageFolder?> TryGetFolderFromPath(Uri folderPath);
/// <summary>
/// Attempts to read folder from the file-system by its path
/// </summary>
/// <param name="wellKnownFolder">Well known folder identifier.</param>
/// <returns>Folder or null if it doesn't exist.</returns>
Task<IStorageFolder?> TryGetWellKnownFolder(WellKnownFolder wellKnownFolder);
}

6
src/Avalonia.Base/Platform/Storage/NameCollisionOption.cs

@ -0,0 +1,6 @@
namespace Avalonia.Platform.Storage;
public class NameCollisionOption
{
}

55
src/Avalonia.Base/Platform/Storage/StorageProviderExtensions.cs

@ -0,0 +1,55 @@
using System.Threading.Tasks;
using Avalonia.Platform.Storage.FileIO;
namespace Avalonia.Platform.Storage;
/// <summary>
/// Group of public extensions for <see cref="IStorageProvider"/> class.
/// </summary>
public static class StorageProviderExtensions
{
/// <inheritdoc cref="IStorageProvider.TryGetFileFromPath"/>
public static Task<IStorageFile?> TryGetFileFromPath(this IStorageProvider provider, string filePath)
{
return provider.TryGetFileFromPath(StorageProviderHelpers.FilePathToUri(filePath));
}
/// <inheritdoc cref="IStorageProvider.TryGetFolderFromPath"/>
public static Task<IStorageFolder?> TryGetFolderFromPath(this IStorageProvider provider, string folderPath)
{
return provider.TryGetFolderFromPath(StorageProviderHelpers.FilePathToUri(folderPath));
}
internal static string? TryGetFullPath(this IStorageFolder folder)
{
// We can avoid double escaping of the path by checking for BclStorageFolder.
// Ideally, `folder.Path.LocalPath` should also work, as that's only available way for the users.
if (folder is BclStorageFolder storageFolder)
{
return storageFolder.DirectoryInfo.FullName;
}
if (folder.Path is { IsAbsoluteUri: true, Scheme: "file" } absolutePath)
{
return absolutePath.LocalPath;
}
// android "content:", browser and ios relative links go here.
return null;
}
internal static string? TryGetFullPath(this IStorageFile file)
{
if (file is BclStorageFile storageFolder)
{
return storageFolder.FileInfo.FullName;
}
if (file.Path is { IsAbsoluteUri: true, Scheme: "file" } absolutePath)
{
return absolutePath.LocalPath;
}
return null;
}
}

37
src/Avalonia.Base/Platform/Storage/WellKnownFolder.cs

@ -0,0 +1,37 @@
namespace Avalonia.Platform.Storage;
/// <summary>
/// Specifies enumerated constants used to retrieve directory paths to system well known folders.
/// </summary>
public enum WellKnownFolder
{
/// <summary>
/// Current user desktop folder.
/// </summary>
Desktop,
/// <summary>
/// Current user documents folder.
/// </summary>
Documents,
/// <summary>
/// Current user downloads folder.
/// </summary>
Downloads,
/// <summary>
/// Current user music folder
/// </summary>
Music,
/// <summary>
/// Current user pictures folder
/// </summary>
Pictures,
/// <summary>
/// Current user videos folder
/// </summary>
Videos,
}

11
src/Avalonia.Controls/Platform/Dialogs/SystemDialogImpl.cs

@ -1,6 +1,7 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Platform.Storage;
#nullable enable
@ -26,9 +27,7 @@ namespace Avalonia.Controls.Platform
var files = await filePicker.OpenFilePickerAsync(options);
return files
.Select(file => file.TryGetUri(out var fullPath)
? fullPath.LocalPath
: file.Name)
.Select(file => file.TryGetFullPath() ?? file.Name)
.ToArray();
}
else if (dialog is SaveFileDialog saveDialog)
@ -47,9 +46,7 @@ namespace Avalonia.Controls.Platform
return null;
}
var filePath = file.TryGetUri(out var fullPath)
? fullPath.LocalPath
: file.Name;
var filePath = file.TryGetFullPath() ?? file.Name;
return new[] { filePath };
}
return null;
@ -67,7 +64,7 @@ namespace Avalonia.Controls.Platform
var folders = await filePicker.OpenFolderPickerAsync(options);
return folders
.Select(f => f.TryGetUri(out var uri) ? uri.LocalPath : null)
.Select(folder => folder.TryGetFullPath() ?? folder.Name)
.FirstOrDefault(u => u is not null);
}
}

5
src/Avalonia.Dialogs/Avalonia.Dialogs.csproj

@ -14,6 +14,11 @@
<ProjectReference Include="..\Markup\Avalonia.Markup.Xaml\Avalonia.Markup.Xaml.csproj" />
</ItemGroup>
<ItemGroup>
<!-- For managed dialogs dev testing -->
<InternalsVisibleTo Include="ControlCatalog, PublicKey=$(AvaloniaPublicKey)" />
</ItemGroup>
<Import Project="..\..\build\ApiDiff.props" />
<Import Project="..\..\build\DevAnalyzers.props" />
<Import Project="..\..\build\TrimmingEnable.props" />

12
src/Avalonia.Dialogs/Internal/ManagedFileChooserViewModel.cs

@ -260,17 +260,7 @@ namespace Avalonia.Dialogs.Internal
public void Navigate(IStorageFolder path, string initialSelectionName = null)
{
string fullDirectoryPath;
if (path?.TryGetUri(out var fullDirectoryUri) == true
&& fullDirectoryUri.IsAbsoluteUri)
{
fullDirectoryPath = fullDirectoryUri.LocalPath;
}
else
{
fullDirectoryPath = Directory.GetCurrentDirectory();
}
var fullDirectoryPath = path?.TryGetFullPath() ?? Directory.GetCurrentDirectory();
Navigate(fullDirectoryPath, initialSelectionName);
}

4
src/Avalonia.Dialogs/ManagedFileDialogExtensions.cs

@ -51,9 +51,7 @@ namespace Avalonia.Dialogs
var files = await impl.OpenFilePickerAsync(dialog.ToFilePickerOpenOptions());
return files
.Select(file => file.TryGetUri(out var fullPath)
? fullPath.LocalPath
: file.Name)
.Select(file => file.TryGetFullPath() ?? file.Name)
.ToArray();
}
}

2
src/Avalonia.Dialogs/ManagedStorageProvider.cs

@ -11,7 +11,7 @@ using Avalonia.Platform.Storage.FileIO;
namespace Avalonia.Dialogs;
public class ManagedStorageProvider<T> : BclStorageProvider where T : Window, new()
internal class ManagedStorageProvider<T> : BclStorageProvider where T : Window, new()
{
private readonly Window _parent;
private readonly ManagedFileDialogOptions _managedOptions;

6
src/Avalonia.FreeDesktop/DBusSystemDialog.cs

@ -88,8 +88,8 @@ namespace Avalonia.FreeDesktop
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()));
if (options.SuggestedStartLocation?.TryGetFullPath() is { } folderPath)
chooserOptions.Add("current_folder", Encoding.UTF8.GetBytes(folderPath));
objectPath = await _fileChooser.SaveFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions);
var request = DBusHelper.Connection!.CreateProxy<IRequest>("org.freedesktop.portal.Request", objectPath);
@ -131,7 +131,7 @@ namespace Avalonia.FreeDesktop
.Where(Directory.Exists)
.Select(path => new BclStorageFolder(new DirectoryInfo(path))).ToList();
}
private static (string name, (uint style, string extension)[])[] ParseFilters(IReadOnlyList<FilePickerFileType>? fileTypes)
{
// Example: [('Images', [(0, '*.ico'), (1, 'image/png')]), ('Text', [(0, '*.txt')])]

9
src/Avalonia.Native/SystemDialogs.cs

@ -33,8 +33,7 @@ namespace Avalonia.Native
{
using var events = new SystemDialogEvents();
var suggestedDirectory = options.SuggestedStartLocation?.TryGetUri(out var suggestedDirectoryTmp) == true
? suggestedDirectoryTmp.LocalPath : string.Empty;
var suggestedDirectory = options.SuggestedStartLocation?.TryGetFullPath() ?? string.Empty;
_native.OpenFileDialog((IAvnWindow)_window.Native,
events,
@ -54,8 +53,7 @@ namespace Avalonia.Native
{
using var events = new SystemDialogEvents();
var suggestedDirectory = options.SuggestedStartLocation?.TryGetUri(out var suggestedDirectoryTmp) == true
? suggestedDirectoryTmp.LocalPath : string.Empty;
var suggestedDirectory = options.SuggestedStartLocation?.TryGetFullPath() ?? string.Empty;
_native.SaveFileDialog((IAvnWindow)_window.Native,
events,
@ -74,8 +72,7 @@ namespace Avalonia.Native
{
using var events = new SystemDialogEvents();
var suggestedDirectory = options.SuggestedStartLocation?.TryGetUri(out var suggestedDirectoryTmp) == true
? suggestedDirectoryTmp.LocalPath : string.Empty;
var suggestedDirectory = options.SuggestedStartLocation?.TryGetFullPath() ?? string.Empty;
_native.SelectFolderDialog((IAvnWindow)_window.Native, events, options.AllowMultiple.AsComBool(), options.Title ?? "", suggestedDirectory);

18
src/Avalonia.X11/NativeDialogs/CompositeStorageProvider.cs

@ -61,4 +61,22 @@ internal class CompositeStorageProvider : IStorageProvider
var provider = await EnsureStorageProvider().ConfigureAwait(false);
return await provider.OpenFolderBookmarkAsync(bookmark).ConfigureAwait(false);
}
public async Task<IStorageFile?> TryGetFileFromPath(Uri filePath)
{
var provider = await EnsureStorageProvider().ConfigureAwait(false);
return await provider.TryGetFileFromPath(filePath).ConfigureAwait(false);
}
public async Task<IStorageFolder?> TryGetFolderFromPath(Uri folderPath)
{
var provider = await EnsureStorageProvider().ConfigureAwait(false);
return await provider.TryGetFolderFromPath(folderPath).ConfigureAwait(false);
}
public async Task<IStorageFolder?> TryGetWellKnownFolder(WellKnownFolder wellKnownFolder)
{
var provider = await EnsureStorageProvider().ConfigureAwait(false);
return await provider.TryGetWellKnownFolder(wellKnownFolder).ConfigureAwait(false);
}
}

10
src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs

@ -59,7 +59,7 @@ namespace Avalonia.X11.NativeDialogs
return res?.Select(f => new BclStorageFolder(new DirectoryInfo(f))).ToArray() ?? Array.Empty<IStorageFolder>();
});
}
public override async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
{
return await await RunOnGlibThread(async () =>
@ -196,10 +196,10 @@ namespace Avalonia.X11.NativeDialogs
gtk_dialog_add_button(dlg, open, GtkResponseType.Cancel);
}
Uri? folderPath = null;
if (initialFolder?.TryGetUri(out folderPath) == true)
var folderLocalPath = initialFolder?.TryGetFullPath();
if (folderLocalPath is not null)
{
using var dir = new Utf8Buffer(folderPath.LocalPath);
using var dir = new Utf8Buffer(folderLocalPath);
gtk_file_chooser_set_current_folder(dlg, dir);
}
@ -207,7 +207,7 @@ namespace Avalonia.X11.NativeDialogs
{
// gtk_file_chooser_set_filename() expects full path
using var fn = action == GtkFileChooserAction.Open
? new Utf8Buffer(Path.Combine(folderPath?.LocalPath ?? "", initialFileName))
? new Utf8Buffer(Path.Combine(folderLocalPath ?? "", initialFileName))
: new Utf8Buffer(initialFileName);
if (action == GtkFileChooserAction.Save)

3
src/Browser/Avalonia.Browser/Interop/StorageHelper.cs

@ -25,6 +25,9 @@ internal static partial class StorageHelper
public static partial Task<JSObject?> SaveFileDialog(JSObject? startIn, string? suggestedName,
[JSMarshalAs<JSType.Array<JSType.Any>>] object[]? types, bool excludeAcceptAllOption);
[JSImport("StorageItem.createWellKnownDirectory", AvaloniaModule.StorageModuleName)]
public static partial JSObject CreateWellKnownDirectory(string wellKnownDirectory);
[JSImport("StorageProvider.openBookmark", AvaloniaModule.StorageModuleName)]
public static partial Task<JSObject?> OpenBookmark(string key);

34
src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs

@ -116,6 +116,33 @@ internal class BrowserStorageProvider : IStorageProvider
return item is not null ? new JSStorageFolder(item) : null;
}
public Task<IStorageFile?> TryGetFileFromPath(Uri filePath)
{
return Task.FromResult<IStorageFile?>(null);
}
public Task<IStorageFolder?> TryGetFolderFromPath(Uri folderPath)
{
return Task.FromResult<IStorageFolder?>(null);
}
public async Task<IStorageFolder?> TryGetWellKnownFolder(WellKnownFolder wellKnownFolder)
{
await _lazyModule.Value;
var directory = StorageHelper.CreateWellKnownDirectory(wellKnownFolder switch
{
WellKnownFolder.Desktop => "desktop",
WellKnownFolder.Documents => "documents",
WellKnownFolder.Downloads => "downloads",
WellKnownFolder.Music => "music",
WellKnownFolder.Pictures => "pictures",
WellKnownFolder.Videos => "videos",
_ => throw new ArgumentOutOfRangeException(nameof(wellKnownFolder), wellKnownFolder, null)
});
return new JSStorageFolder(directory);
}
private static (JSObject[]? types, bool excludeAllOption) ConvertFileTypes(IEnumerable<FilePickerFileType>? input)
{
var types = input?
@ -145,12 +172,7 @@ internal abstract class JSStorageItem : IStorageBookmarkItem
internal JSObject FileHandle => _fileHandle ?? throw new ObjectDisposedException(nameof(JSStorageItem));
public string Name => FileHandle.GetPropertyAsString("name") ?? string.Empty;
public bool TryGetUri([NotNullWhen(true)] out Uri? uri)
{
uri = new Uri(Name, UriKind.Relative);
return false;
}
public Uri Path => new Uri(Name, UriKind.Relative);
public async Task<StorageItemProperties> GetBasicPropertiesAsync()
{

34
src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts

@ -1,14 +1,29 @@
import { avaloniaDb, fileBookmarksStore } from "./indexedDb";
export class StorageItem {
constructor(public handle: FileSystemHandle, private readonly bookmarkId?: string) { }
constructor(
public handle?: FileSystemHandle,
private readonly bookmarkId?: string,
public wellKnownType?: WellKnownDirectory
) {
}
public get name(): string {
return this.handle.name;
if (this.handle) {
return this.handle.name;
}
return this.wellKnownType ?? "";
}
public get kind(): "file" | "directory" {
if (this.handle) {
return this.handle.kind;
}
return "directory";
}
public get kind(): string {
return this.handle.kind;
public static createWellKnownDirectory(type: WellKnownDirectory) {
return new StorageItem(undefined, undefined, type);
}
public static async openRead(item: StorageItem): Promise<Blob> {
@ -48,7 +63,7 @@ export class StorageItem {
}
public static async getItems(item: StorageItem): Promise<StorageItems> {
if (item.handle.kind !== "directory") {
if (item.kind !== "directory" || !item.handle) {
return new StorageItems([]);
}
@ -60,6 +75,10 @@ export class StorageItem {
}
private async verityPermissions(mode: FileSystemPermissionMode): Promise<void | never> {
if (!this.handle) {
return;
}
if (await this.handle.queryPermission({ mode }) === "granted") {
return;
}
@ -69,11 +88,14 @@ export class StorageItem {
}
}
public static async saveBookmark(item: StorageItem): Promise<string> {
public static async saveBookmark(item: StorageItem): Promise<string | null> {
// If file was previously bookmarked, just return old one.
if (item.bookmarkId) {
return item.bookmarkId;
}
if (!item.handle) {
return null;
}
const connection = await avaloniaDb.connect();
try {

6
src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts

@ -17,7 +17,7 @@ export class StorageProvider {
startIn: StorageItem | null): Promise<StorageItem> {
// 'Picker' API doesn't accept "null" as a parameter, so it should be set to undefined.
const options: DirectoryPickerOptions = {
startIn: (startIn?.handle ?? undefined)
startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined)
};
const handle = await window.showDirectoryPicker(options);
@ -28,7 +28,7 @@ export class StorageProvider {
startIn: StorageItem | null, multiple: boolean,
types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean): Promise<StorageItems> {
const options: OpenFilePickerOptions = {
startIn: (startIn?.handle ?? undefined),
startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined),
multiple,
excludeAcceptAllOption,
types: (types ?? undefined)
@ -42,7 +42,7 @@ export class StorageProvider {
startIn: StorageItem | null, suggestedName: string | null,
types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean): Promise<StorageItem> {
const options: SaveFilePickerOptions = {
startIn: (startIn?.handle ?? undefined),
startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined),
suggestedName: (suggestedName ?? undefined),
excludeAcceptAllOption,
types: (types ?? undefined)

4
src/Windows/Avalonia.Win32/Win32StorageProvider.cs

@ -134,10 +134,10 @@ namespace Avalonia.Win32
}
}
if (folder?.TryGetUri(out var folderPath) == true)
if (folder?.TryGetFullPath() is { } folderPath)
{
var riid = UnmanagedMethods.ShellIds.IShellItem;
if (UnmanagedMethods.SHCreateItemFromParsingName(folderPath.LocalPath, IntPtr.Zero, ref riid, out var directoryShellItem)
if (UnmanagedMethods.SHCreateItemFromParsingName(folderPath, IntPtr.Zero, ref riid, out var directoryShellItem)
== (uint)UnmanagedMethods.HRESULT.S_OK)
{
var proxy = MicroComRuntime.CreateProxyFor<IShellItem>(directoryShellItem, true);

41
src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs

@ -25,7 +25,7 @@ internal abstract class IOSStorageItem : IStorageBookmarkItem
using (var doc = new UIDocument(url))
{
_filePath = doc.FileUrl?.Path ?? url.FilePathUrl.Path;
Name = doc.LocalizedName ?? Path.GetFileName(_filePath) ?? url.FilePathUrl.LastPathComponent;
Name = doc.LocalizedName ?? System.IO.Path.GetFileName(_filePath) ?? url.FilePathUrl.LastPathComponent;
}
}
@ -34,6 +34,7 @@ internal abstract class IOSStorageItem : IStorageBookmarkItem
public bool CanBookmark => true;
public string Name { get; }
public Uri Path => Url!;
public Task<StorageItemProperties> GetBasicPropertiesAsync()
{
@ -83,12 +84,6 @@ internal abstract class IOSStorageItem : IStorageBookmarkItem
}
}
public bool TryGetUri([NotNullWhen(true)] out Uri uri)
{
uri = Url;
return uri is not null;
}
public void Dispose()
{
}
@ -121,18 +116,34 @@ internal sealed class IOSStorageFolder : IOSStorageItem, IStorageBookmarkFolder
{
}
public Task<IReadOnlyList<IStorageItem>> GetItemsAsync()
public async Task<IReadOnlyList<IStorageItem>> GetItemsAsync()
{
var content = NSFileManager.DefaultManager.GetDirectoryContent(Url, null, NSDirectoryEnumerationOptions.None, out var error);
var tcs = new TaskCompletionSource<IReadOnlyList<IStorageItem>>();
new NSFileCoordinator().CoordinateRead(Url,
NSFileCoordinatorReadingOptions.WithoutChanges,
out var error,
uri =>
{
var content = NSFileManager.DefaultManager.GetDirectoryContent(uri, null, NSDirectoryEnumerationOptions.None, out var error);
if (error is not null)
{
tcs.TrySetException(new NSErrorException(error));
}
else
{
var items = content
.Select(u => u.HasDirectoryPath ? (IStorageItem)new IOSStorageFolder(u) : new IOSStorageFile(u))
.ToArray();
tcs.TrySetResult(items);
}
});
if (error is not null)
{
return Task.FromException<IReadOnlyList<IStorageItem>>(new NSErrorException(error));
throw new NSErrorException(error);
}
var items = content
.Select(u => u.HasDirectoryPath ? (IStorageItem)new IOSStorageFolder(u) : new IOSStorageFile(u))
.ToArray();
return Task.FromResult<IReadOnlyList<IStorageItem>>(items);
return await tcs.Task;
}
}

50
src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs

@ -3,6 +3,7 @@ using System.Linq;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Logging;
using Avalonia.Platform.Storage;
using UIKit;
@ -99,6 +100,40 @@ internal class IOSStorageProvider : IStorageProvider
? new IOSStorageFolder(url) : null);
}
public Task<IStorageFile?> TryGetFileFromPath(Uri filePath)
{
// TODO: research if it's possible, maybe with additional permissions.
return Task.FromResult<IStorageFile?>(null);
}
public Task<IStorageFolder?> TryGetFolderFromPath(Uri folderPath)
{
// TODO: research if it's possible, maybe with additional permissions.
return Task.FromResult<IStorageFolder?>(null);
}
public Task<IStorageFolder?> TryGetWellKnownFolder(WellKnownFolder wellKnownFolder)
{
var directoryType = wellKnownFolder switch
{
WellKnownFolder.Desktop => NSSearchPathDirectory.DesktopDirectory,
WellKnownFolder.Documents => NSSearchPathDirectory.DocumentDirectory,
WellKnownFolder.Downloads => NSSearchPathDirectory.DownloadsDirectory,
WellKnownFolder.Music => NSSearchPathDirectory.MusicDirectory,
WellKnownFolder.Pictures => NSSearchPathDirectory.PicturesDirectory,
WellKnownFolder.Videos => NSSearchPathDirectory.MoviesDirectory,
_ => throw new ArgumentOutOfRangeException(nameof(wellKnownFolder), wellKnownFolder, null)
};
var uri = NSFileManager.DefaultManager.GetUrl(directoryType, NSSearchPathDomain.Local, null, true, out var error);
if (error != null)
{
throw new NSErrorException(error);
}
return Task.FromResult<IStorageFolder?>(new IOSStorageFolder(uri));
}
public Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
{
return Task.FromException<IStorageFile?>(
@ -127,17 +162,12 @@ internal class IOSStorageProvider : IStorageProvider
private static NSUrl? GetUrlFromFolder(IStorageFolder? folder)
{
if (folder is IOSStorageFolder iosFolder)
return folder switch
{
return iosFolder.Url;
}
if (folder?.TryGetUri(out var fullPath) == true)
{
return fullPath;
}
return null;
IOSStorageFolder iosFolder => iosFolder.Url,
null => null,
_ => folder.Path
};
}
private Task<NSUrl[]> ShowPicker(UIDocumentPickerViewController documentPicker)

15
tests/Avalonia.Base.UnitTests/Utilities/UriExtensionsTests.cs

@ -1,4 +1,5 @@
using System;
using Avalonia.Platform.Storage.FileIO;
using Avalonia.Utilities;
using Xunit;
@ -26,4 +27,18 @@ public class UriExtensionsTests
Assert.Equal(string.Empty, name);
}
[Theory]
[InlineData("C://Work/Projects.txt")]
[InlineData("/home/Projects.txt")]
[InlineData("/home/Stahování/Požární kniha 2.txt")]
[InlineData("C:\\%51.txt")]
[InlineData("/home/asd#xcv.txt")]
[InlineData("C:\\\\Work\\Projects.txt")]
public void Should_Convert_File_Path_To_Uri_And_Back(string path)
{
var uri = StorageProviderHelpers.FilePathToUri(path);
Assert.Equal(path, uri.LocalPath);
}
}

Loading…
Cancel
Save