Browse Source

IStorageProvider API updates

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

+ 16 - 1
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"

+ 46 - 13
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 - 0
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 - 0
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 - 0
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));
+        }
+    }
+}

+ 70 - 31
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)
             {

+ 113 - 11
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);
         }

+ 32 - 39
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)
     {
     }

+ 29 - 34
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)
     {
     }

+ 99 - 4
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);
 }

+ 15 - 3
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);

+ 2 - 2
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.

+ 34 - 1
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 - 0
src/Avalonia.Base/Platform/Storage/NameCollisionOption.cs

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

+ 55 - 0
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 - 0
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,
+}

+ 4 - 7
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 - 0
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" />

+ 1 - 11
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);
         }

+ 1 - 3
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();
         }
     }

+ 1 - 1
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;

+ 3 - 3
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')])]

+ 3 - 6
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 - 0
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);
+    }
 }

+ 5 - 5
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 - 0
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);
 

+ 28 - 6
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()
     {

+ 28 - 6
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 {

+ 3 - 3
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)

+ 2 - 2
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);

+ 26 - 15
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;
     }
 }

+ 40 - 10
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 - 0
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);
+    }
 }