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..f0c22d7f38 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 CreateFileAsync(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 CreateFolderAsync(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)
+ {
+ await 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.CreateFolderAsync(storageFolder.Name) as AndroidStorageFolder;
+
+ if (destination == null)
+ {
+ return null;
+ }
+
+ await 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,76 @@ 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.CreateFileAsync(Name) as AndroidStorageFile;
+
+ try
+ {
+ 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);
+ }
+ }
+ catch
+ {
+ newFile?.DeleteAsync();
+ }
+
+ 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..8c657fb787 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 CreateFileAsync(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 CreateFolderAsync(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..a9d1ff3669 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 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 CreateFolderAsync(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);
}
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..56833e448c 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 }) 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");
+ }
+
+ await item.verityPermissions("readwrite");
+
+ return await ((item.handle as any).getDirectoryHandle(name, { create: true }) as 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 }) as Promise);
+ }
+
+ 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 */) as Promise);
+ }
+
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);
+ }
}