From c276f6e1b3a57684c3a836c9365c83a829f70489 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Wed, 8 Mar 2023 19:43:17 +0000 Subject: [PATCH 1/5] add new IStorage api --- .../Properties/AndroidManifest.xml | 2 + .../Avalonia.Android/Avalonia.Android.csproj | 1 + .../Platform/Storage/AndroidStorageItem.cs | 234 +++++++++++++++++- .../Platform/Storage/FileIO/BclStorageFile.cs | 18 ++ .../Storage/FileIO/BclStorageFolder.cs | 35 +++ .../Platform/Storage/IStorageFolder.cs | 14 ++ .../Platform/Storage/IStorageItem.cs | 13 + 7 files changed, 305 insertions(+), 12 deletions(-) diff --git a/samples/ControlCatalog.Android/Properties/AndroidManifest.xml b/samples/ControlCatalog.Android/Properties/AndroidManifest.xml index 6f551d2b01..ec07a94b22 100644 --- a/samples/ControlCatalog.Android/Properties/AndroidManifest.xml +++ b/samples/ControlCatalog.Android/Properties/AndroidManifest.xml @@ -2,4 +2,6 @@ + + diff --git a/src/Android/Avalonia.Android/Avalonia.Android.csproj b/src/Android/Avalonia.Android/Avalonia.Android.csproj index 2533016e9f..d8b0c3d534 100644 --- a/src/Android/Avalonia.Android/Avalonia.Android.csproj +++ b/src/Android/Avalonia.Android/Avalonia.Android.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs index 8052b3911a..69b61051e9 100644 --- a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs +++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs @@ -8,7 +8,10 @@ using System.Threading.Tasks; using Android; using Android.App; using Android.Content; +using Android.OS; using Android.Provider; +using Android.Webkit; +using AndroidX.DocumentFile.Provider; using Avalonia.Logging; using Avalonia.Platform.Storage; using Java.Lang; @@ -22,20 +25,25 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem { private Activity? _activity; private readonly bool _needsExternalFilesPermission; + private readonly AndroidStorageFolder? _parent; + private readonly AndroidUri? _permissionRoot; - protected AndroidStorageItem(Activity activity, AndroidUri uri, bool needsExternalFilesPermission) + protected AndroidStorageItem(Activity activity, AndroidUri uri, bool needsExternalFilesPermission, AndroidStorageFolder? parent = null, AndroidUri? permissionRoot = null) { _activity = activity; _needsExternalFilesPermission = needsExternalFilesPermission; + _parent = parent; + _permissionRoot = permissionRoot ?? parent?.Uri ?? Uri; Uri = uri; } - internal AndroidUri Uri { get; } + internal AndroidUri Uri { get; set; } protected Activity Activity => _activity ?? throw new ObjectDisposedException(nameof(AndroidStorageItem)); public virtual string Name => GetColumnValue(Activity, Uri, MediaStore.IMediaColumns.DisplayName) - ?? Uri.PathSegments?.LastOrDefault() ?? string.Empty; + ?? Document?.Name + ?? Uri.PathSegments?.LastOrDefault()?.Split("/", StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? string.Empty; public Uri Path => new(Uri.ToString()!); @@ -92,6 +100,23 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem return null; } + if(_parent != null) + { + return _parent; + } + + var document = Document; + + if (document == null) + { + return null; + } + + if(document.ParentFile != null) + { + return new AndroidStorageFolder(Activity, document.ParentFile.Uri, true); + } + using var javaFile = new JavaFile(Uri.Path!); // Java file represents files AND directories. Don't be confused. @@ -118,12 +143,90 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem { _activity = null; } + + internal DocumentFile? Document + { + get + { + if (this is AndroidStorageFile) + { + return DocumentFile.FromSingleUri(Activity, Uri); + } + else + { + return DocumentFile.FromTreeUri(Activity, Uri); + } + } + } + + internal AndroidUri? PermissionRoot => _permissionRoot; + + public abstract Task DeleteAsync(); + + public abstract Task MoveAsync(IStorageFolder destination); } internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder { - public AndroidStorageFolder(Activity activity, AndroidUri uri, bool needsExternalFilesPermission) : base(activity, uri, needsExternalFilesPermission) + public AndroidStorageFolder(Activity activity, AndroidUri uri, bool needsExternalFilesPermission, AndroidStorageFolder? parent = null, AndroidUri? permissionRoot = null) : base(activity, uri, needsExternalFilesPermission, parent, permissionRoot) + { + } + + public async Task CreateFile(string name) + { + var mimeType = MimeTypeMap.Singleton?.GetMimeTypeFromExtension(MimeTypeMap.GetFileExtensionFromUrl(name)) ?? "application/octet-stream"; + var newFile = Document.CreateFile(mimeType, name); + + if(newFile == null) + { + return null; + } + + return new AndroidStorageFile(Activity, newFile.Uri, this); + } + + public async Task CreateFolder(string name) { + var newFolder = Document?.CreateDirectory(name); + + if (newFolder == null) + { + return null; + } + + return new AndroidStorageFolder(Activity, newFolder.Uri, false, this, PermissionRoot); + } + + public override async Task DeleteAsync() + { + if (!await EnsureExternalFilesPermission(false)) + { + return; + } + + if (Activity != null) + { + await DeleteContents(this); + } + + async Task DeleteContents(AndroidStorageFolder storageFolder) + { + var files = await storageFolder.GetItemsAsync(); + + foreach (var file in files) + { + if(file is AndroidStorageFolder folder) + { + await DeleteContents(folder); + } + else if(file is AndroidStorageFile storageFile) + { + await storageFile.DeleteAsync(); + } + } + + DocumentFile.FromTreeUri(Activity, storageFolder.Uri)?.Delete(); + } } public override Task GetBasicPropertiesAsync() @@ -144,7 +247,9 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder yield break; } - var childrenUri = DocumentsContract.BuildChildDocumentsUriUsingTree(Uri!, DocumentsContract.GetTreeDocumentId(Uri)); + var root = PermissionRoot ?? Uri; + var folderId = root != Uri ? DocumentsContract.GetDocumentId(Uri) : DocumentsContract.GetTreeDocumentId(Uri); + var childrenUri = DocumentsContract.BuildChildDocumentsUriUsingTree(root, folderId); var projection = new[] { @@ -160,17 +265,57 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder { var mime = cursor.GetString(1); var id = cursor.GetString(0); - var uri = DocumentsContract.BuildDocumentUriUsingTree(Uri!, id); + + bool isDirectory = mime == DocumentsContract.Document.MimeTypeDir; + var uri = DocumentsContract.BuildDocumentUriUsingTree(root, id); + if (uri == null) { continue; } - yield return mime == DocumentsContract.Document.MimeTypeDir ? new AndroidStorageFolder(Activity, uri, false) : - new AndroidStorageFile(Activity, uri); + yield return isDirectory ? new AndroidStorageFolder(Activity, uri, false, this, root) : + new AndroidStorageFile(Activity, uri, this, root); + } + } + } + + public override async Task MoveAsync(IStorageFolder destination) + { + if (Activity != null) + { + return await MoveRecursively(this, (AndroidStorageFolder)destination); + } + + return null; + + async Task MoveRecursively(AndroidStorageFolder storageFolder, AndroidStorageFolder destination) + { + destination = await destination.CreateFolder(storageFolder.Name) as AndroidStorageFolder; + + if (destination == null) + { + return null; + } + var files = await storageFolder.GetItemsAsync(); + + foreach (var file in files) + { + if (file is AndroidStorageFolder folder) + { + await MoveRecursively(folder, destination); } + else if (file is AndroidStorageFile) + { + await file.MoveAsync(destination); + } + } + + await storageFolder.DeleteAsync(); + + return destination; } - } + } } internal sealed class WellKnownAndroidStorageFolder : AndroidStorageFolder @@ -182,14 +327,14 @@ internal sealed class WellKnownAndroidStorageFolder : AndroidStorageFolder } public override string Name { get; } -} +} internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkFile { - public AndroidStorageFile(Activity activity, AndroidUri uri) : base(activity, uri, false) + public AndroidStorageFile(Activity activity, AndroidUri uri, AndroidStorageFolder? parent = null, AndroidUri? permissionRoot = null) : base(activity, uri, false, parent, permissionRoot) { } - + public Task OpenReadAsync() => Task.FromResult(OpenContentStream(Activity, Uri, false) ?? throw new InvalidOperationException("Failed to open content stream")); @@ -313,4 +458,69 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF return Task.FromResult(new StorageItemProperties(size, itemDate, dateModified)); } + + public override async Task DeleteAsync() + { + if (!await EnsureExternalFilesPermission(false)) + { + return; + } + + if (Activity != null) + { + DocumentsContract.DeleteDocument(Activity.ContentResolver!, Uri); + } + } + + public override async Task MoveAsync(IStorageFolder destination) + { + if (!await EnsureExternalFilesPermission(false)) + { + return null; + } + + if (Activity != null && destination is AndroidStorageFolder storageFolder) + { + if (Build.VERSION.SdkInt >= BuildVersionCodes.N) + { + try + { + var uri = DocumentsContract.MoveDocument(Activity.ContentResolver!, Uri, ((await GetParentAsync()) as AndroidStorageFolder)!.Uri, storageFolder.Document!.Uri); + + return new AndroidStorageFile(Activity, uri, storageFolder); + } + catch (Exception ex) + { + // There are many reason why DocumentContract will fail to move a file. We fallback to copying. + return await MoveFileByCopy(); + + } + } + else + { + return await MoveFileByCopy(); + } + } + + async Task MoveFileByCopy() + { + var newFile = await storageFolder.CreateFile(Name) as AndroidStorageFile; + + if (newFile != null) + { + using var input = await OpenReadAsync(); + using var output = await newFile.OpenWriteAsync(); + + await input.CopyToAsync(output); + + await DeleteAsync(); + + return new AndroidStorageFile(Activity, newFile.Uri, storageFolder); + } + + return null; + } + + return null; + } } diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs index 543fb0ab74..f9f02ec339 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs @@ -92,4 +92,22 @@ internal class BclStorageFile : IStorageBookmarkFile Dispose(disposing: true); GC.SuppressFinalize(this); } + + public async Task DeleteAsync() + { + FileInfo.Delete(); + } + + public async Task MoveAsync(IStorageFolder destination) + { + if (destination is BclStorageFolder storageFolder) + { + var newPath = System.IO.Path.Combine(storageFolder.DirectoryInfo.FullName, FileInfo.Name); + FileInfo.MoveTo(newPath); + + return new BclStorageFile(new FileInfo(newPath)); + } + + return null; + } } diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs index e6551390d6..b9e74104c8 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs @@ -94,4 +94,39 @@ internal class BclStorageFolder : IStorageBookmarkFolder Dispose(disposing: true); GC.SuppressFinalize(this); } + + public async Task DeleteAsync() + { + DirectoryInfo.Delete(true); + } + + public async Task MoveAsync(IStorageFolder destination) + { + if (destination is BclStorageFolder storageFolder) + { + var newPath = System.IO.Path.Combine(storageFolder.DirectoryInfo.FullName, DirectoryInfo.Name); + DirectoryInfo.MoveTo(newPath); + + return new BclStorageFolder(new DirectoryInfo(newPath)); + } + + return null; + } + + public async Task CreateFile(string name) + { + var fileName = System.IO.Path.Combine(DirectoryInfo.FullName, name); + var newFile = new FileInfo(fileName); + + using var stream = newFile.Create(); + + return new BclStorageFile(newFile); + } + + public async Task CreateFolder(string name) + { + var newFolder = DirectoryInfo.CreateSubdirectory(name); + + return new BclStorageFolder(newFolder); + } } diff --git a/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs b/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs index 52b3256387..61feeba79a 100644 --- a/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs +++ b/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs @@ -17,4 +17,18 @@ public interface IStorageFolder : IStorageItem /// When this method completes successfully, it returns a list of the files and folders in the current folder. Each item in the list is represented by an implementation object. /// IAsyncEnumerable GetItemsAsync(); + + /// + /// Creates a file with specified name as a child of the current storage folder + /// + /// The display name + /// A new pointing to the moved file. If not null, the current storage item becomes invalid + Task CreateFile(string name); + + /// + /// Creates a folder with specified name as a child of the current storage folder + /// + /// The display name + /// A new pointing to the moved file. If not null, the current storage item becomes invalid + Task CreateFolder(string name); } diff --git a/src/Avalonia.Base/Platform/Storage/IStorageItem.cs b/src/Avalonia.Base/Platform/Storage/IStorageItem.cs index c2c93400b0..b5873fdb27 100644 --- a/src/Avalonia.Base/Platform/Storage/IStorageItem.cs +++ b/src/Avalonia.Base/Platform/Storage/IStorageItem.cs @@ -50,4 +50,17 @@ public interface IStorageItem : IDisposable /// Gets the parent folder of the current storage item. /// Task GetParentAsync(); + + /// + /// Deletes the current storage item and it's contents + /// + /// + Task DeleteAsync(); + + /// + /// Moves the current storage item and it's contents to a + /// + /// The to move the item into + /// + Task MoveAsync(IStorageFolder destination); } From 16cb38f60d8296f4eb6f664a9b5eefe42a468f7e Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Wed, 8 Mar 2023 19:43:17 +0000 Subject: [PATCH 2/5] add new IStorage api --- .../Properties/AndroidManifest.xml | 2 + .../Avalonia.Android/Avalonia.Android.csproj | 1 + .../Platform/Storage/AndroidStorageItem.cs | 232 +++++++++++++++++- .../Platform/Storage/FileIO/BclStorageFile.cs | 18 ++ .../Storage/FileIO/BclStorageFolder.cs | 35 +++ .../Platform/Storage/IStorageFolder.cs | 14 ++ .../Platform/Storage/IStorageItem.cs | 13 + 7 files changed, 302 insertions(+), 13 deletions(-) diff --git a/samples/ControlCatalog.Android/Properties/AndroidManifest.xml b/samples/ControlCatalog.Android/Properties/AndroidManifest.xml index 6f551d2b01..ec07a94b22 100644 --- a/samples/ControlCatalog.Android/Properties/AndroidManifest.xml +++ b/samples/ControlCatalog.Android/Properties/AndroidManifest.xml @@ -2,4 +2,6 @@ + + diff --git a/src/Android/Avalonia.Android/Avalonia.Android.csproj b/src/Android/Avalonia.Android/Avalonia.Android.csproj index 2533016e9f..d8b0c3d534 100644 --- a/src/Android/Avalonia.Android/Avalonia.Android.csproj +++ b/src/Android/Avalonia.Android/Avalonia.Android.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs index 8052b3911a..2dc0fa774b 100644 --- a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs +++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs @@ -8,7 +8,10 @@ using System.Threading.Tasks; using Android; using Android.App; using Android.Content; +using Android.OS; using Android.Provider; +using Android.Webkit; +using AndroidX.DocumentFile.Provider; using Avalonia.Logging; using Avalonia.Platform.Storage; using Java.Lang; @@ -22,20 +25,25 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem { private Activity? _activity; private readonly bool _needsExternalFilesPermission; + private readonly AndroidStorageFolder? _parent; + private readonly AndroidUri? _permissionRoot; - protected AndroidStorageItem(Activity activity, AndroidUri uri, bool needsExternalFilesPermission) + protected AndroidStorageItem(Activity activity, AndroidUri uri, bool needsExternalFilesPermission, AndroidStorageFolder? parent = null, AndroidUri? permissionRoot = null) { _activity = activity; _needsExternalFilesPermission = needsExternalFilesPermission; + _parent = parent; + _permissionRoot = permissionRoot ?? parent?.Uri ?? Uri; Uri = uri; } - internal AndroidUri Uri { get; } + internal AndroidUri Uri { get; set; } protected Activity Activity => _activity ?? throw new ObjectDisposedException(nameof(AndroidStorageItem)); public virtual string Name => GetColumnValue(Activity, Uri, MediaStore.IMediaColumns.DisplayName) - ?? Uri.PathSegments?.LastOrDefault() ?? string.Empty; + ?? Document?.Name + ?? Uri.PathSegments?.LastOrDefault()?.Split("/", StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? string.Empty; public Uri Path => new(Uri.ToString()!); @@ -92,6 +100,23 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem return null; } + if(_parent != null) + { + return _parent; + } + + var document = Document; + + if (document == null) + { + return null; + } + + if(document.ParentFile != null) + { + return new AndroidStorageFolder(Activity, document.ParentFile.Uri, true); + } + using var javaFile = new JavaFile(Uri.Path!); // Java file represents files AND directories. Don't be confused. @@ -118,12 +143,88 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem { _activity = null; } + + internal DocumentFile? Document + { + get + { + if (this is AndroidStorageFile) + { + return DocumentFile.FromSingleUri(Activity, Uri); + } + else + { + return DocumentFile.FromTreeUri(Activity, Uri); + } + } + } + + internal AndroidUri? PermissionRoot => _permissionRoot; + + public abstract Task DeleteAsync(); + + public abstract Task MoveAsync(IStorageFolder destination); } internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder { - public AndroidStorageFolder(Activity activity, AndroidUri uri, bool needsExternalFilesPermission) : base(activity, uri, needsExternalFilesPermission) + public AndroidStorageFolder(Activity activity, AndroidUri uri, bool needsExternalFilesPermission, AndroidStorageFolder? parent = null, AndroidUri? permissionRoot = null) : base(activity, uri, needsExternalFilesPermission, parent, permissionRoot) + { + } + + public async Task CreateFile(string name) + { + var mimeType = MimeTypeMap.Singleton?.GetMimeTypeFromExtension(MimeTypeMap.GetFileExtensionFromUrl(name)) ?? "application/octet-stream"; + var newFile = Document.CreateFile(mimeType, name); + + if(newFile == null) + { + return null; + } + + return new AndroidStorageFile(Activity, newFile.Uri, this); + } + + public async Task CreateFolder(string name) { + var newFolder = Document?.CreateDirectory(name); + + if (newFolder == null) + { + return null; + } + + return new AndroidStorageFolder(Activity, newFolder.Uri, false, this, PermissionRoot); + } + + public override async Task DeleteAsync() + { + if (!await EnsureExternalFilesPermission(false)) + { + return; + } + + if (Activity != null) + { + await DeleteContents(this); + } + + async Task DeleteContents(AndroidStorageFolder storageFolder) + { + foreach (var file in storageFolder.GetItemsAsync()) + { + if(file is AndroidStorageFolder folder) + { + await DeleteContents(folder); + } + else if(file is AndroidStorageFile storageFile) + { + await storageFile.DeleteAsync(); + } + } + + DocumentFile.FromTreeUri(Activity, storageFolder.Uri)?.Delete(); + } } public override Task GetBasicPropertiesAsync() @@ -137,14 +238,16 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder { yield break; } - + var contentResolver = Activity.ContentResolver; if (contentResolver == null) { yield break; } - var childrenUri = DocumentsContract.BuildChildDocumentsUriUsingTree(Uri!, DocumentsContract.GetTreeDocumentId(Uri)); + var root = PermissionRoot ?? Uri; + var folderId = root != Uri ? DocumentsContract.GetDocumentId(Uri) : DocumentsContract.GetTreeDocumentId(Uri); + var childrenUri = DocumentsContract.BuildChildDocumentsUriUsingTree(root, folderId); var projection = new[] { @@ -160,17 +263,55 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder { var mime = cursor.GetString(1); var id = cursor.GetString(0); - var uri = DocumentsContract.BuildDocumentUriUsingTree(Uri!, id); + + bool isDirectory = mime == DocumentsContract.Document.MimeTypeDir; + var uri = DocumentsContract.BuildDocumentUriUsingTree(root, id); + if (uri == null) { continue; } + yield return isDirectory ? new AndroidStorageFolder(Activity, uri, false, this, root) : + new AndroidStorageFile(Activity, uri, this, root); + } + } + } - yield return mime == DocumentsContract.Document.MimeTypeDir ? new AndroidStorageFolder(Activity, uri, false) : - new AndroidStorageFile(Activity, uri); + public override async Task MoveAsync(IStorageFolder destination) + { + if (Activity != null) + { + return await MoveRecursively(this, (AndroidStorageFolder)destination); + } + + return null; + + async Task MoveRecursively(AndroidStorageFolder storageFolder, AndroidStorageFolder destination) + { + destination = await destination.CreateFolder(storageFolder.Name) as AndroidStorageFolder; + + if (destination == null) + { + return null; + } + + foreach (var file in storageFolder.GetItemsAsync()) + { + if (file is AndroidStorageFolder folder) + { + await MoveRecursively(folder, destination); + } + else if (file is AndroidStorageFile) + { + await file.MoveAsync(destination); } + } + + await storageFolder.DeleteAsync(); + + return destination; } - } + } } internal sealed class WellKnownAndroidStorageFolder : AndroidStorageFolder @@ -182,14 +323,14 @@ internal sealed class WellKnownAndroidStorageFolder : AndroidStorageFolder } public override string Name { get; } -} +} internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkFile { - public AndroidStorageFile(Activity activity, AndroidUri uri) : base(activity, uri, false) + public AndroidStorageFile(Activity activity, AndroidUri uri, AndroidStorageFolder? parent = null, AndroidUri? permissionRoot = null) : base(activity, uri, false, parent, permissionRoot) { } - + public Task OpenReadAsync() => Task.FromResult(OpenContentStream(Activity, Uri, false) ?? throw new InvalidOperationException("Failed to open content stream")); @@ -313,4 +454,69 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF return Task.FromResult(new StorageItemProperties(size, itemDate, dateModified)); } + + public override async Task DeleteAsync() + { + if (!await EnsureExternalFilesPermission(false)) + { + return; + } + + if (Activity != null) + { + DocumentsContract.DeleteDocument(Activity.ContentResolver!, Uri); + } + } + + public override async Task MoveAsync(IStorageFolder destination) + { + if (!await EnsureExternalFilesPermission(false)) + { + return null; + } + + if (Activity != null && destination is AndroidStorageFolder storageFolder) + { + if (Build.VERSION.SdkInt >= BuildVersionCodes.N) + { + try + { + var uri = DocumentsContract.MoveDocument(Activity.ContentResolver!, Uri, ((await GetParentAsync()) as AndroidStorageFolder)!.Uri, storageFolder.Document!.Uri); + + return new AndroidStorageFile(Activity, uri, storageFolder); + } + catch (Exception ex) + { + // There are many reason why DocumentContract will fail to move a file. We fallback to copying. + return await MoveFileByCopy(); + + } + } + else + { + return await MoveFileByCopy(); + } + } + + async Task MoveFileByCopy() + { + var newFile = await storageFolder.CreateFile(Name) as AndroidStorageFile; + + if (newFile != null) + { + using var input = await OpenReadAsync(); + using var output = await newFile.OpenWriteAsync(); + + await input.CopyToAsync(output); + + await DeleteAsync(); + + return new AndroidStorageFile(Activity, newFile.Uri, storageFolder); + } + + return null; + } + + return null; + } } diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs index 543fb0ab74..f9f02ec339 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs @@ -92,4 +92,22 @@ internal class BclStorageFile : IStorageBookmarkFile Dispose(disposing: true); GC.SuppressFinalize(this); } + + public async Task DeleteAsync() + { + FileInfo.Delete(); + } + + public async Task MoveAsync(IStorageFolder destination) + { + if (destination is BclStorageFolder storageFolder) + { + var newPath = System.IO.Path.Combine(storageFolder.DirectoryInfo.FullName, FileInfo.Name); + FileInfo.MoveTo(newPath); + + return new BclStorageFile(new FileInfo(newPath)); + } + + return null; + } } diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs index e6551390d6..b9e74104c8 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs @@ -94,4 +94,39 @@ internal class BclStorageFolder : IStorageBookmarkFolder Dispose(disposing: true); GC.SuppressFinalize(this); } + + public async Task DeleteAsync() + { + DirectoryInfo.Delete(true); + } + + public async Task MoveAsync(IStorageFolder destination) + { + if (destination is BclStorageFolder storageFolder) + { + var newPath = System.IO.Path.Combine(storageFolder.DirectoryInfo.FullName, DirectoryInfo.Name); + DirectoryInfo.MoveTo(newPath); + + return new BclStorageFolder(new DirectoryInfo(newPath)); + } + + return null; + } + + public async Task CreateFile(string name) + { + var fileName = System.IO.Path.Combine(DirectoryInfo.FullName, name); + var newFile = new FileInfo(fileName); + + using var stream = newFile.Create(); + + return new BclStorageFile(newFile); + } + + public async Task CreateFolder(string name) + { + var newFolder = DirectoryInfo.CreateSubdirectory(name); + + return new BclStorageFolder(newFolder); + } } diff --git a/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs b/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs index 52b3256387..61feeba79a 100644 --- a/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs +++ b/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs @@ -17,4 +17,18 @@ public interface IStorageFolder : IStorageItem /// When this method completes successfully, it returns a list of the files and folders in the current folder. Each item in the list is represented by an implementation object. /// IAsyncEnumerable GetItemsAsync(); + + /// + /// Creates a file with specified name as a child of the current storage folder + /// + /// The display name + /// A new pointing to the moved file. If not null, the current storage item becomes invalid + Task CreateFile(string name); + + /// + /// Creates a folder with specified name as a child of the current storage folder + /// + /// The display name + /// A new pointing to the moved file. If not null, the current storage item becomes invalid + Task CreateFolder(string name); } diff --git a/src/Avalonia.Base/Platform/Storage/IStorageItem.cs b/src/Avalonia.Base/Platform/Storage/IStorageItem.cs index c2c93400b0..b5873fdb27 100644 --- a/src/Avalonia.Base/Platform/Storage/IStorageItem.cs +++ b/src/Avalonia.Base/Platform/Storage/IStorageItem.cs @@ -50,4 +50,17 @@ public interface IStorageItem : IDisposable /// Gets the parent folder of the current storage item. /// Task GetParentAsync(); + + /// + /// Deletes the current storage item and it's contents + /// + /// + Task DeleteAsync(); + + /// + /// Moves the current storage item and it's contents to a + /// + /// The to move the item into + /// + Task MoveAsync(IStorageFolder destination); } From 2d2fcf94acb23678d5d087e4e2d647e22c6f5f78 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Thu, 9 Mar 2023 13:06:38 +0000 Subject: [PATCH 3/5] add async suffix --- .../Platform/Storage/AndroidStorageItem.cs | 33 +++++++++++-------- .../Storage/FileIO/BclStorageFolder.cs | 4 +-- .../Platform/Storage/IStorageFolder.cs | 4 +-- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs index 2dc0fa774b..f0c22d7f38 100644 --- a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs +++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs @@ -172,7 +172,7 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder { } - public async Task CreateFile(string name) + public async Task CreateFileAsync(string name) { var mimeType = MimeTypeMap.Singleton?.GetMimeTypeFromExtension(MimeTypeMap.GetFileExtensionFromUrl(name)) ?? "application/octet-stream"; var newFile = Document.CreateFile(mimeType, name); @@ -185,7 +185,7 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder return new AndroidStorageFile(Activity, newFile.Uri, this); } - public async Task CreateFolder(string name) + public async Task CreateFolderAsync(string name) { var newFolder = Document?.CreateDirectory(name); @@ -211,7 +211,7 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder async Task DeleteContents(AndroidStorageFolder storageFolder) { - foreach (var file in storageFolder.GetItemsAsync()) + await foreach (var file in storageFolder.GetItemsAsync()) { if(file is AndroidStorageFolder folder) { @@ -288,14 +288,14 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder async Task MoveRecursively(AndroidStorageFolder storageFolder, AndroidStorageFolder destination) { - destination = await destination.CreateFolder(storageFolder.Name) as AndroidStorageFolder; + destination = await destination.CreateFolderAsync(storageFolder.Name) as AndroidStorageFolder; if (destination == null) { return null; } - foreach (var file in storageFolder.GetItemsAsync()) + await foreach (var file in storageFolder.GetItemsAsync()) { if (file is AndroidStorageFolder folder) { @@ -498,20 +498,27 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF } } - async Task MoveFileByCopy() + async Task MoveFileByCopy() { - var newFile = await storageFolder.CreateFile(Name) as AndroidStorageFile; + var newFile = await storageFolder.CreateFileAsync(Name) as AndroidStorageFile; - if (newFile != null) + try { - using var input = await OpenReadAsync(); - using var output = await newFile.OpenWriteAsync(); + if (newFile != null) + { + using var input = await OpenReadAsync(); + using var output = await newFile.OpenWriteAsync(); - await input.CopyToAsync(output); + await input.CopyToAsync(output); - await DeleteAsync(); + await DeleteAsync(); - return new AndroidStorageFile(Activity, newFile.Uri, storageFolder); + return new AndroidStorageFile(Activity, newFile.Uri, storageFolder); + } + } + catch + { + newFile?.DeleteAsync(); } return null; diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs index b9e74104c8..8c657fb787 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs @@ -113,7 +113,7 @@ internal class BclStorageFolder : IStorageBookmarkFolder return null; } - public async Task CreateFile(string name) + public async Task CreateFileAsync(string name) { var fileName = System.IO.Path.Combine(DirectoryInfo.FullName, name); var newFile = new FileInfo(fileName); @@ -123,7 +123,7 @@ internal class BclStorageFolder : IStorageBookmarkFolder return new BclStorageFile(newFile); } - public async Task CreateFolder(string name) + public async Task CreateFolderAsync(string name) { var newFolder = DirectoryInfo.CreateSubdirectory(name); diff --git a/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs b/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs index 61feeba79a..a9d1ff3669 100644 --- a/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs +++ b/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs @@ -23,12 +23,12 @@ public interface IStorageFolder : IStorageItem /// /// The display name /// A new pointing to the moved file. If not null, the current storage item becomes invalid - Task CreateFile(string name); + Task CreateFileAsync(string name); /// /// Creates a folder with specified name as a child of the current storage folder /// /// The display name /// A new pointing to the moved file. If not null, the current storage item becomes invalid - Task CreateFolder(string name); + Task CreateFolderAsync(string name); } From d19e3cd89808592461af5f2d085d8321eba2b94f Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 10 Mar 2023 13:49:43 +0900 Subject: [PATCH 4/5] Add wasm and ios implementations --- .../Avalonia.Browser/Interop/StorageHelper.cs | 12 ++++ .../Storage/BrowserStorageProvider.cs | 63 +++++++++++++++++++ .../webapp/modules/storage/storageItem.ts | 43 +++++++++++++ .../Avalonia.iOS/Storage/IOSStorageItem.cs | 62 ++++++++++++++++++ 4 files changed, 180 insertions(+) diff --git a/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs b/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs index dc3372d2d0..c56023c0f7 100644 --- a/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs +++ b/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs @@ -52,4 +52,16 @@ internal static partial class StorageHelper [JSImport("StorageProvider.createAcceptType", AvaloniaModule.StorageModuleName)] public static partial JSObject CreateAcceptType(string description, string[] mimeTypes, string[]? extensions); + + [JSImport("StorageProvider.deleteAsync", AvaloniaModule.StorageModuleName)] + public static partial Task DeleteAsync(JSObject fileHandle); + + [JSImport("StorageProvider.moveAsync", AvaloniaModule.StorageModuleName)] + public static partial Task MoveAsync(JSObject fileHandle, JSObject destinationFolder); + + [JSImport("StorageProvider.createFile", AvaloniaModule.StorageModuleName)] + public static partial Task CreateFile(JSObject folderHandle, string name); + + [JSImport("StorageProvider.createFolder", AvaloniaModule.StorageModuleName)] + public static partial Task CreateFolder(JSObject folderHandle, string name); } diff --git a/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs b/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs index fcb956f294..e6849f2fcc 100644 --- a/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs +++ b/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs @@ -199,6 +199,33 @@ internal abstract class JSStorageItem : IStorageBookmarkItem return Task.FromResult(null); } + public Task DeleteAsync() + { + return StorageHelper.DeleteAsync(FileHandle); + } + + public async Task MoveAsync(IStorageFolder destination) + { + if (destination is not JSStorageFolder folder) + { + throw new InvalidOperationException("Destination folder must be initialized the StorageProvider API."); + } + + var storageItem = await StorageHelper.MoveAsync(FileHandle, folder.FileHandle); + if (storageItem is null) + { + return null; + } + + var kind = storageItem.GetPropertyAsString("kind"); + return kind switch + { + "directory" => new JSStorageFolder(storageItem), + "file" => new JSStorageFile(storageItem), + _ => this + }; + } + public Task ReleaseBookmarkAsync() { if (!CanBookmark) @@ -299,4 +326,40 @@ internal class JSStorageFolder : JSStorageItem, IStorageBookmarkFolder } } } + + public async Task CreateFileAsync(string name) + { + try + { + var storageFile = await StorageHelper.CreateFile(FileHandle, name); + if (storageFile is null) + { + return null; + } + + return new JSStorageFile(storageFile); + } + catch (JSException ex) when (ex.Message == BrowserStorageProvider.NoPermissionsMessage) + { + throw new UnauthorizedAccessException("User denied permissions to open the file", ex); + } + } + + public async Task CreateFolderAsync(string name) + { + try + { + var storageFile = await StorageHelper.CreateFolder(FileHandle, name); + if (storageFile is null) + { + return null; + } + + return new JSStorageFolder(storageFile); + } + catch (JSException ex) when (ex.Message == BrowserStorageProvider.NoPermissionsMessage) + { + throw new UnauthorizedAccessException("User denied permissions to open the file", ex); + } + } } diff --git a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts index 399e268915..384a56f4aa 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts @@ -97,6 +97,49 @@ export class StorageItem { return (item.handle as any).entries(); } + public static async createFile(item: StorageItem, name: string) : Promise { + if (item.kind !== "directory" || !item.handle) { + throw new TypeError("Unable to create item in the requested directory"); + } + + await item.verityPermissions("readwrite"); + + return await (item.handle as any).getFileHandle(name, { create: true }); + } + + 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"); + } + + await item.verityPermissions("readwrite"); + + return await (item.handle as any).getDirectoryHandle(name, { create: true }); + } + + public static async deleteAsync(item: StorageItem) : Promise { + if (!item.handle) { + return null; + } + + await item.verityPermissions("readwrite"); + + return await (item.handle as any).remove({recursive: true}); + } + + public static async moveAsync(item: StorageItem, destination: StorageItem) : Promise { + if (!item.handle) { + return null; + } + if (destination.kind !== "directory" || !destination.handle) { + throw new TypeError("Unable to move item to the requested directory"); + } + + await item.verityPermissions("readwrite"); + + return await (item.handle as any).move(destination /*, newName */); + } + private async verityPermissions(mode: "read" | "readwrite"): Promise { if (!this.handle) { return; diff --git a/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs b/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs index baf31ebc73..7ab593e121 100644 --- a/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs +++ b/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs @@ -32,6 +32,7 @@ internal abstract class IOSStorageItem : IStorageBookmarkItem } internal NSUrl Url { get; } + internal string FilePath => _filePath; public bool CanBookmark => true; @@ -54,6 +55,38 @@ internal abstract class IOSStorageItem : IStorageBookmarkItem return Task.FromResult(new IOSStorageFolder(Url.RemoveLastPathComponent())); } + public Task DeleteAsync() + { + return NSFileManager.DefaultManager.Remove(Url, out var error) + ? Task.CompletedTask + : Task.FromException(new NSErrorException(error)); + } + + public async Task MoveAsync(IStorageFolder destination) + { + if (destination is not IOSStorageFolder folder) + { + throw new InvalidOperationException("Destination folder must be initialized the StorageProvider API."); + } + + var isDir = this is IStorageFolder; + var newPath = new NSUrl(System.IO.Path.Combine(folder.FilePath, Name), isDir); + + if (NSFileManager.DefaultManager.Move(folder.Url, newPath, out var error)) + { + return isDir + ? new IOSStorageFolder(newPath) + : new IOSStorageFile(newPath); + } + + if (error is not null) + { + throw new NSErrorException(error); + } + + return null; + } + public Task ReleaseBookmarkAsync() { // no-op @@ -149,4 +182,33 @@ internal sealed class IOSStorageFolder : IOSStorageItem, IStorageBookmarkFolder yield return item; } } + + public Task CreateFileAsync(string name) + { + var path = System.IO.Path.Combine(FilePath, name); + NSFileAttributes? attributes = null; + if (NSFileManager.DefaultManager.CreateFile(path, null, attributes)) + { + return Task.FromResult(new IOSStorageFile(new NSUrl(path, false))); + } + + return Task.FromResult(null); + } + + public Task CreateFolderAsync(string name) + { + var path = System.IO.Path.Combine(FilePath, name); + NSFileAttributes? attributes = null; + if (NSFileManager.DefaultManager.CreateDirectory(path, false, attributes, out var error)) + { + return Task.FromResult(new IOSStorageFolder(new NSUrl(path, true))); + } + + if (error is not null) + { + throw new NSErrorException(error); + } + + return Task.FromResult(null); + } } From 2d58c4c215280cce29a97de72402b1649ccf18e8 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 10 Mar 2023 13:53:00 +0900 Subject: [PATCH 5/5] Fix TS buikd --- .../webapp/modules/storage/storageItem.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts index 384a56f4aa..56833e448c 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts @@ -97,37 +97,37 @@ export class StorageItem { return (item.handle as any).entries(); } - public static async createFile(item: StorageItem, name: string) : Promise { + public static async createFile(item: StorageItem, name: string): Promise { if (item.kind !== "directory" || !item.handle) { throw new TypeError("Unable to create item in the requested directory"); } await item.verityPermissions("readwrite"); - return await (item.handle as any).getFileHandle(name, { create: true }); + return await ((item.handle as any).getFileHandle(name, { create: true }) as Promise); } - public static async createFolder(item: StorageItem, name: string) : 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"); } await item.verityPermissions("readwrite"); - return await (item.handle as any).getDirectoryHandle(name, { create: true }); + return await ((item.handle as any).getDirectoryHandle(name, { create: true }) as Promise); } - public static async deleteAsync(item: StorageItem) : Promise { + public static async deleteAsync(item: StorageItem): Promise { if (!item.handle) { return null; } await item.verityPermissions("readwrite"); - return await (item.handle as any).remove({recursive: true}); + return await ((item.handle as any).remove({ recursive: true }) as Promise); } - public static async moveAsync(item: StorageItem, destination: StorageItem) : Promise { + public static async moveAsync(item: StorageItem, destination: StorageItem): Promise { if (!item.handle) { return null; } @@ -137,7 +137,7 @@ export class StorageItem { await item.verityPermissions("readwrite"); - return await (item.handle as any).move(destination /*, newName */); + return await ((item.handle as any).move(destination /*, newName */) as Promise); } private async verityPermissions(mode: "read" | "readwrite"): Promise {