diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index f6797ada37..6b0f1c59cc 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -49,6 +49,18 @@ baseline/netstandard2.0/Avalonia.Controls.dll target/netstandard2.0/Avalonia.Controls.dll + + CP0006 + M:Avalonia.Platform.Storage.IStorageFolder.GetFileAsync(System.String) + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Platform.Storage.IStorageFolder.GetFolderAsync(System.String) + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + CP0006 M:Avalonia.Controls.Notifications.IManagedNotificationManager.Close(System.Object) diff --git a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs index bb27379a70..330f2ded86 100644 --- a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs +++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs @@ -294,6 +294,79 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder return destination; } } + + private async Task GetItemAsync(string name, bool isDirectory) + { + if (!await EnsureExternalFilesPermission(false)) + { + return null; + } + + var contentResolver = Activity.ContentResolver; + if (contentResolver == null) + { + return null; + } + + var root = PermissionRoot ?? Uri; + var folderId = root != Uri ? DocumentsContract.GetDocumentId(Uri) : DocumentsContract.GetTreeDocumentId(Uri); + var childrenUri = DocumentsContract.BuildChildDocumentsUriUsingTree(root, folderId); + + var projection = new[] + { + DocumentsContract.Document.ColumnDocumentId, + DocumentsContract.Document.ColumnMimeType, + DocumentsContract.Document.ColumnDisplayName + }; + + if (childrenUri != null) + { + using var cursor = contentResolver.Query(childrenUri, projection, null, null, null); + if (cursor != null) + { + while (cursor.MoveToNext()) + { + var id = cursor.GetString(0); + var mime = cursor.GetString(1); + + var fileName = cursor.GetString(2); + if (fileName != name) + { + continue; + } + + bool mineDirectory = mime == DocumentsContract.Document.MimeTypeDir; + if (isDirectory != mineDirectory) + { + return null; + } + + var uri = DocumentsContract.BuildDocumentUriUsingTree(root, id); + if (uri == null) + { + return null; + } + + return isDirectory ? new AndroidStorageFolder(Activity, uri, false, this, root) : + new AndroidStorageFile(Activity, uri, this, root); + } + } + } + + return null; + } + + public async Task GetFolderAsync(string name) + { + var folder = await GetItemAsync(name, true); + return (IStorageFolder?)folder; + } + + public async Task GetFileAsync(string name) + { + var file = await GetItemAsync(name, false); + return (IStorageFile?)file; + } } internal sealed class WellKnownAndroidStorageFolder : AndroidStorageFolder diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs index 05572d6058..73f75355d6 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs @@ -19,4 +19,10 @@ internal sealed class BclStorageFolder(DirectoryInfo directoryInfo) public Task CreateFolderAsync(string name) => Task.FromResult( (IStorageFolder?)WrapFileSystemInfo(CreateFolderCore(directoryInfo, name))); + + public Task GetFolderAsync(string name) => Task.FromResult( + (IStorageFolder?)WrapFileSystemInfo(GetFolderCore(directoryInfo, name))); + + public Task GetFileAsync(string name) => Task.FromResult( + (IStorageFile?)WrapFileSystemInfo(GetFileCore(directoryInfo, name))); } diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageItem.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageItem.cs index 123d0e9283..294fc2c579 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageItem.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageItem.cs @@ -127,6 +127,28 @@ internal abstract class BclStorageItem(FileSystemInfo fileSystemInfo) : IStorage .OfType() .Concat(directoryInfo.EnumerateFiles()); + internal static FileSystemInfo? GetFolderCore(DirectoryInfo directoryInfo, string name) + { + var path = System.IO.Path.Combine(directoryInfo.FullName, name); + if (Directory.Exists(path)) + { + return new DirectoryInfo(path); + } + + return null; + } + + internal static FileSystemInfo? GetFileCore(DirectoryInfo directoryInfo, string name) + { + var path = System.IO.Path.Combine(directoryInfo.FullName, name); + if (File.Exists(path)) + { + return new FileInfo(path); + } + + return null; + } + internal static FileInfo CreateFileCore(DirectoryInfo directoryInfo, string name) { var fileName = System.IO.Path.Combine(directoryInfo.FullName, name); diff --git a/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs b/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs index a9d1ff3669..b6d8fd4daf 100644 --- a/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs +++ b/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs @@ -18,6 +18,24 @@ public interface IStorageFolder : IStorageItem /// IAsyncEnumerable GetItemsAsync(); + /// + /// Gets the folder with the specified name from the current folder. + /// + /// The name of the folder to get + /// + /// When this method completes successfully, it returns the folder with the specified name from the current folder. + /// + Task GetFolderAsync(string name); + + /// + /// Gets the file with the specified name from the current folder. + /// + /// The name of the file to get + /// + /// When this method completes successfully, it returns the file with the specified name from the current folder. + /// + Task GetFileAsync(string name); + /// /// Creates a file with specified name as a child of the current storage folder /// diff --git a/src/Avalonia.Native/StorageItem.cs b/src/Avalonia.Native/StorageItem.cs index 12e4cc0a5e..efa0404a34 100644 --- a/src/Avalonia.Native/StorageItem.cs +++ b/src/Avalonia.Native/StorageItem.cs @@ -149,4 +149,18 @@ internal class StorageFolder( var folder = BclStorageItem.CreateFolderCore(directoryInfo, name); return Task.FromResult((IStorageFolder?)WrapFileSystemInfo(folder, ScopeOwnerUri)); } + + public Task GetFolderAsync(string name) + { + using var scope = OpenScope(); + var item = BclStorageItem.GetFolderCore(directoryInfo, name); + return Task.FromResult((IStorageFolder?)WrapFileSystemInfo(item, ScopeOwnerUri)); + } + + public Task GetFileAsync(string name) + { + using var scope = OpenScope(); + var item = BclStorageItem.GetFileCore(directoryInfo, name); + return Task.FromResult((IStorageFile?)WrapFileSystemInfo(item, ScopeOwnerUri)); + } } diff --git a/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs b/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs index c28efbb308..76f1273f2e 100644 --- a/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs +++ b/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs @@ -67,4 +67,10 @@ internal static partial class StorageHelper [JSImport("StorageProvider.createFolder", AvaloniaModule.StorageModuleName)] public static partial Task CreateFolder(JSObject folderHandle, string name); + + [JSImport("StorageItem.getFile", AvaloniaModule.StorageModuleName)] + public static partial Task GetFile(JSObject folderHandle, string name); + + [JSImport("StorageItem.getFolder", AvaloniaModule.StorageModuleName)] + public static partial Task GetFolder(JSObject folderHandle, string name); } diff --git a/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs b/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs index 6a89a13e02..cef13b4d3b 100644 --- a/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs +++ b/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs @@ -16,6 +16,8 @@ internal class BrowserStorageProvider : IStorageProvider internal static ReadOnlySpan BrowserBookmarkKey => "browser"u8; internal const string PickerCancelMessage = "The user aborted a request"; internal const string NoPermissionsMessage = "Permissions denied"; + internal const string FileFolderNotFoundMessage = "A requested file or directory could not be found"; + internal const string TypeMissmatchMessage = "The path supplied exists, but was not an entry of requested type"; public bool CanOpen => true; public bool CanSave => true; @@ -385,4 +387,45 @@ internal class JSStorageFolder : JSStorageItem, IStorageBookmarkFolder throw new UnauthorizedAccessException("User denied permissions to open the file", ex); } } + + public async Task GetFolderAsync(string name) + { + try + { + var storageFile = await StorageHelper.GetFolder(FileHandle, name); + if (storageFile is null) + { + return null; + } + + return new JSStorageFolder(storageFile); + } + catch (JSException ex) when (ShouldSupressErrorOnFileAccess(ex)) + { + return null; + } + } + + public async Task GetFileAsync(string name) + { + try + { + var storageFile = await StorageHelper.GetFile(FileHandle, name); + if (storageFile is null) + { + return null; + } + + return new JSStorageFile(storageFile); + } + catch (JSException ex) when (ShouldSupressErrorOnFileAccess(ex)) + { + return null; + } + } + + private static bool ShouldSupressErrorOnFileAccess(JSException ex) => + ex.Message == BrowserStorageProvider.NoPermissionsMessage || + ex.Message.Contains(BrowserStorageProvider.TypeMissmatchMessage, StringComparison.Ordinal) || + ex.Message.Contains(BrowserStorageProvider.FileFolderNotFoundMessage, StringComparison.Ordinal); } diff --git a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts index 56833e448c..f30e6f5916 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts @@ -107,6 +107,16 @@ export class StorageItem { return await ((item.handle as any).getFileHandle(name, { create: true }) as Promise); } + public static async getFile(item: StorageItem, name: string): Promise { + if (item.kind !== "directory" || !item.handle) { + return null; + } + + await item.verityPermissions("read"); + + return await ((item.handle as any).getFileHandle(name) as Promise); + } + public static async createFolder(item: StorageItem, name: string): Promise { if (item.kind !== "directory" || !item.handle) { throw new TypeError("Unable to create item in the requested directory"); @@ -117,6 +127,16 @@ export class StorageItem { return await ((item.handle as any).getDirectoryHandle(name, { create: true }) as Promise); } + public static async getFolder(item: StorageItem, name: string): Promise { + if (item.kind !== "directory" || !item.handle) { + return null; + } + + await item.verityPermissions("read"); + + return await ((item.handle as any).getDirectoryHandle(name) as Promise); + } + public static async deleteAsync(item: StorageItem): Promise { if (!item.handle) { return null; diff --git a/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs b/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs index fa086c7d61..069b1e8d49 100644 --- a/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs +++ b/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs @@ -306,4 +306,36 @@ internal sealed class IOSStorageFolder : IOSStorageItem, IStorageBookmarkFolder SecurityScopedAncestorUrl.StopAccessingSecurityScopedResource(); } } + + private NSUrl? GetItem(string name, bool isDirectory) + { + try + { + SecurityScopedAncestorUrl.StartAccessingSecurityScopedResource(); + + var path = System.IO.Path.Combine(FilePath, name); + if (NSFileManager.DefaultManager.FileExists(path, ref isDirectory)) + { + return new NSUrl(path, isDirectory); + } + + return null; + } + finally + { + SecurityScopedAncestorUrl.StopAccessingSecurityScopedResource(); + } + } + + public Task GetFolderAsync(string name) + { + var url = GetItem(name, true); + return Task.FromResult(url is null ? null : new IOSStorageFolder(url)); + } + + public Task GetFileAsync(string name) + { + var url = GetItem(name, false); + return Task.FromResult(url is null ? null : new IOSStorageFile(url)); + } }