From e121481a23a4a667fee279e3b21e9bd3c027ad9d Mon Sep 17 00:00:00 2001 From: Rowe Wilson Frederisk Holme Date: Wed, 22 Apr 2026 01:44:46 +0800 Subject: [PATCH] Standardize the behavior of CreateFile/CreateFolder across different OS (#20850) * Truncate the content when writing To maintain consistency with other operating system APIs, see: https://github.com/AvaloniaUI/Avalonia/pull/20804 & https://github.com/AvaloniaUI/Avalonia/pull/20807 * Prevent accidental truncation when creating files * Update comments * Validate existing item type when creating storage * Truncate files on creation in storage items --------- Co-authored-by: Julien Lebosquain --- .../Platform/Storage/AndroidStorageItem.cs | 50 +++++++++++++++---- .../Platform/Storage/IStorageFolder.cs | 12 +++-- .../Platform/Storage/IStorageItem.cs | 1 - .../webapp/modules/storage/storageItem.ts | 9 ++-- .../Avalonia.iOS/Storage/IOSStorageItem.cs | 2 +- 5 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs index 59ec332b2d..2ee0236828 100644 --- a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs +++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs @@ -157,29 +157,61 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder { } - public Task CreateFileAsync(string name) + public async Task CreateFileAsync(string name) { - var mimeType = MimeTypeMap.Singleton?.GetMimeTypeFromExtension(MimeTypeMap.GetFileExtensionFromUrl(name)) ?? "application/octet-stream"; + // Try to return an existing file to avoid creating file (1). + var existingItem = await GetItemAsync(name, false); + if (existingItem != null) + { + if (existingItem is IStorageFile existingFile) + { + // The file should be truncated when it is created. + using (var _ = await existingFile.OpenWriteAsync()) { } + return existingFile; + } + else if (existingItem is IStorageFolder) + { + // There is an item with the same name but it's not a file. We can't create a file in this case. + throw new IOException($"Can not create '{name}' because a directory with the same name already exists."); + } + } + // Create new one and return it. var treeUri = GetTreeUri().treeUri; - var newFile = CreateDocument(Activity.ContentResolver!, treeUri!, mimeType, name); - if (newFile == null) + var mimeType = MimeTypeMap.Singleton?.GetMimeTypeFromExtension(MimeTypeMap.GetFileExtensionFromUrl(name)) ?? "application/octet-stream"; + var newFile = DocumentsContract.CreateDocument(Activity.ContentResolver!, treeUri!, mimeType, name); + if(newFile == null) { - return Task.FromResult(null); + return null; } - return Task.FromResult(new AndroidStorageFile(Activity, newFile, this)); + return new AndroidStorageFile(Activity, newFile, this); } - public Task CreateFolderAsync(string name) + public async Task CreateFolderAsync(string name) { + // Try to return an existing folder to avoid creating folder (1). + var existingItem = await GetItemAsync(name, true); + if (existingItem != null) + { + if (existingItem is IStorageFolder existingFolder) + { + return existingFolder; + } + else if (existingItem is IStorageFile) + { + // There is an item with the same name but it's not a folder. We can't create a folder in this case. + throw new IOException($"Can not create '{name}' because a file with the same name already exists."); + } + } + // Create new one and return it. var treeUri = GetTreeUri().treeUri; var newFolder = CreateDocument(Activity.ContentResolver!, treeUri!, Document.MimeTypeDir, name); if (newFolder == null) { - return Task.FromResult(null); + return null; } - return Task.FromResult(new AndroidStorageFolder(Activity, newFolder, false, this, PermissionRoot)); + return new AndroidStorageFolder(Activity, newFolder, false, this, PermissionRoot); } public override async Task DeleteAsync() diff --git a/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs b/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs index b6d8fd4daf..ac4dd8cd2c 100644 --- a/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs +++ b/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs @@ -37,16 +37,20 @@ public interface IStorageFolder : IStorageItem Task GetFileAsync(string name); /// - /// Creates a file with specified name as a child of the current storage folder + /// Creates, or truncates and overwrites, 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 + /// + /// A that provides read/write access to the file specified in name. + /// Task CreateFileAsync(string name); /// - /// Creates a folder with specified name as a child of the current storage folder + /// Creates a folder with specified name as a child of the current storage folder unless they already exist. /// /// The display name - /// A new pointing to the moved file. If not null, the current storage item becomes invalid + /// + /// A that represents the directory at the specified name. This object is returned regardless of whether a directory at the specified name already exists. + /// Task CreateFolderAsync(string name); } diff --git a/src/Avalonia.Base/Platform/Storage/IStorageItem.cs b/src/Avalonia.Base/Platform/Storage/IStorageItem.cs index b5873fdb27..59d039582b 100644 --- a/src/Avalonia.Base/Platform/Storage/IStorageItem.cs +++ b/src/Avalonia.Base/Platform/Storage/IStorageItem.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Avalonia.Metadata; diff --git a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts index f30e6f5916..2d30d1901b 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts @@ -65,7 +65,7 @@ export class StorageItem { await item.verityPermissions("readwrite"); - return await (item.handle as FileSystemFileHandle).createWritable({ keepExistingData: true }); + return await (item.handle as FileSystemFileHandle).createWritable({ keepExistingData: false }); } public static async getProperties(item: StorageItem): Promise<{ Size: number; LastModified: number; Type: string } | null> { @@ -103,8 +103,11 @@ export class StorageItem { } await item.verityPermissions("readwrite"); - - return await ((item.handle as any).getFileHandle(name, { create: true }) as Promise); + // The file should be truncated when it is created. + const fileHandle = await ((item.handle as any).getFileHandle(name, { create: true }) as Promise); + const writable = await fileHandle.createWritable({ keepExistingData: false }); + await writable.close(); + return fileHandle; } public static async getFile(item: StorageItem, name: string): Promise { diff --git a/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs b/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs index 663b1fdd20..60360fa34d 100644 --- a/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs +++ b/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs @@ -299,7 +299,7 @@ internal sealed class IOSStorageFolder : IOSStorageItem, IStorageBookmarkFolder var path = System.IO.Path.Combine(FilePath, name); NSFileAttributes? attributes = null; - if (NSFileManager.DefaultManager.CreateDirectory(path, false, attributes, out var error)) + if (NSFileManager.DefaultManager.CreateDirectory(path, true, attributes, out var error)) { return Task.FromResult(new IOSStorageFolder(new NSUrl(path, true), SecurityScopedAncestorUrl)); }