Browse Source

Merge branch 'master' into wasm-pen

pull/8430/head
Max Katz 4 years ago
committed by GitHub
parent
commit
1732c1d2d9
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj
  2. 52
      native/Avalonia.Native/src/OSX/SystemDialogs.mm
  3. 2
      samples/ControlCatalog/ControlCatalog.csproj
  4. 3
      samples/ControlCatalog/MainView.xaml
  5. 5
      samples/ControlCatalog/MainView.xaml.cs
  6. 76
      samples/ControlCatalog/Pages/DialogsPage.xaml
  7. 234
      samples/ControlCatalog/Pages/DialogsPage.xaml.cs
  8. 6
      samples/ControlCatalog/Pages/NumericUpDownPage.xaml
  9. 13
      samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs
  10. 3
      src/Android/Avalonia.Android/AndroidPlatform.cs
  11. 32
      src/Android/Avalonia.Android/AvaloniaActivity.cs
  12. 8
      src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs
  13. 244
      src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
  14. 177
      src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs
  15. 20
      src/Android/Avalonia.Android/SystemDialogImpl.cs
  16. 10
      src/Avalonia.Base/Logging/LogArea.cs
  17. 107
      src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs
  18. 88
      src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs
  19. 35
      src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs
  20. 40
      src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs
  21. 44
      src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs
  22. 48
      src/Avalonia.Base/Platform/Storage/FilePickerFileTypes.cs
  23. 19
      src/Avalonia.Base/Platform/Storage/FilePickerOpenOptions.cs
  24. 29
      src/Avalonia.Base/Platform/Storage/FilePickerSaveOptions.cs
  25. 12
      src/Avalonia.Base/Platform/Storage/FolderPickerOpenOptions.cs
  26. 20
      src/Avalonia.Base/Platform/Storage/IStorageBookmarkItem.cs
  27. 32
      src/Avalonia.Base/Platform/Storage/IStorageFile.cs
  28. 11
      src/Avalonia.Base/Platform/Storage/IStorageFolder.cs
  29. 53
      src/Avalonia.Base/Platform/Storage/IStorageItem.cs
  30. 56
      src/Avalonia.Base/Platform/Storage/IStorageProvider.cs
  31. 17
      src/Avalonia.Base/Platform/Storage/PickerOptions.cs
  32. 43
      src/Avalonia.Base/Platform/Storage/StorageItemProperties.cs
  33. 12
      src/Avalonia.Controls/Platform/Dialogs/IStorageProviderFactory.cs
  34. 2
      src/Avalonia.Controls/Platform/Dialogs/ISystemDialogImpl.cs
  35. 74
      src/Avalonia.Controls/Platform/Dialogs/SystemDialogImpl.cs
  36. 11
      src/Avalonia.Controls/Platform/ITopLevelImplWithStorageProvider.cs
  37. 57
      src/Avalonia.Controls/SystemDialog.cs
  38. 9
      src/Avalonia.Controls/TopLevel.cs
  39. 6
      src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs
  40. 1
      src/Avalonia.DesignerSupport/Remote/PreviewerWindowingPlatform.cs
  41. 32
      src/Avalonia.DesignerSupport/Remote/Stubs.cs
  42. 4
      src/Avalonia.Dialogs/Avalonia.Dialogs.csproj
  43. 35
      src/Avalonia.Dialogs/ManagedFileChooserFilterViewModel.cs
  44. 97
      src/Avalonia.Dialogs/ManagedFileChooserViewModel.cs
  45. 142
      src/Avalonia.Dialogs/ManagedFileDialogExtensions.cs
  46. 147
      src/Avalonia.Dialogs/ManagedStorageProvider.cs
  47. 159
      src/Avalonia.FreeDesktop/DBusSystemDialog.cs
  48. 1
      src/Avalonia.Headless/AvaloniaHeadlessPlatform.cs
  49. 36
      src/Avalonia.Headless/HeadlessPlatformStubs.cs
  50. 6
      src/Avalonia.Headless/HeadlessWindowImpl.cs
  51. 1
      src/Avalonia.Native/AvaloniaNativePlatform.cs
  52. 118
      src/Avalonia.Native/SystemDialogs.cs
  53. 7
      src/Avalonia.Native/WindowImplBase.cs
  54. 7
      src/Avalonia.Native/avn.idl
  55. 3
      src/Avalonia.X11/NativeDialogs/Gtk.cs
  56. 225
      src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs
  57. 7
      src/Avalonia.X11/X11Platform.cs
  58. 9
      src/Avalonia.X11/X11Window.cs
  59. 15
      src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs
  60. 6
      src/Web/Avalonia.Web.Blazor/Interop/JSModuleInterop.cs
  61. 200
      src/Web/Avalonia.Web.Blazor/Interop/Storage/StorageProviderInterop.cs
  62. 124
      src/Web/Avalonia.Web.Blazor/Interop/Storage/WriteableStream.cs
  63. 292
      src/Web/Avalonia.Web.Blazor/Interop/Typescript/StorageProvider.ts
  64. 4
      src/Web/Avalonia.Web.Blazor/RazorViewTopLevelImpl.cs
  65. 9
      src/Web/Avalonia.Web.Blazor/WinStubs.cs
  66. 1
      src/Web/Avalonia.Web.Blazor/WindowingPlatform.cs
  67. 1
      src/Windows/Avalonia.Win32/Win32Platform.cs
  68. 205
      src/Windows/Avalonia.Win32/Win32StorageProvider.cs
  69. 7
      src/Windows/Avalonia.Win32/WindowImpl.cs
  70. 9
      src/iOS/Avalonia.iOS/AvaloniaView.cs
  71. 66
      src/iOS/Avalonia.iOS/Storage/IOSSecurityScopedStream.cs
  72. 121
      src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs
  73. 212
      src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs

4
native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj

@ -49,6 +49,7 @@
AB8F7D6B21482D7F0057DBA5 /* platformthreading.mm in Sources */ = {isa = PBXBuildFile; fileRef = AB8F7D6A21482D7F0057DBA5 /* platformthreading.mm */; }; AB8F7D6B21482D7F0057DBA5 /* platformthreading.mm in Sources */ = {isa = PBXBuildFile; fileRef = AB8F7D6A21482D7F0057DBA5 /* platformthreading.mm */; };
BC11A5BE2608D58F0017BAD0 /* automation.h in Headers */ = {isa = PBXBuildFile; fileRef = BC11A5BC2608D58F0017BAD0 /* automation.h */; }; BC11A5BE2608D58F0017BAD0 /* automation.h in Headers */ = {isa = PBXBuildFile; fileRef = BC11A5BC2608D58F0017BAD0 /* automation.h */; };
BC11A5BF2608D58F0017BAD0 /* automation.mm in Sources */ = {isa = PBXBuildFile; fileRef = BC11A5BD2608D58F0017BAD0 /* automation.mm */; }; BC11A5BF2608D58F0017BAD0 /* automation.mm in Sources */ = {isa = PBXBuildFile; fileRef = BC11A5BD2608D58F0017BAD0 /* automation.mm */; };
ED3791C42862E1F40080BD62 /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = ED3791C32862E1F40080BD62 /* UniformTypeIdentifiers.framework */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@ -101,6 +102,7 @@
AB8F7D6A21482D7F0057DBA5 /* platformthreading.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = platformthreading.mm; sourceTree = "<group>"; }; AB8F7D6A21482D7F0057DBA5 /* platformthreading.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = platformthreading.mm; sourceTree = "<group>"; };
BC11A5BC2608D58F0017BAD0 /* automation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = automation.h; sourceTree = "<group>"; }; BC11A5BC2608D58F0017BAD0 /* automation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = automation.h; sourceTree = "<group>"; };
BC11A5BD2608D58F0017BAD0 /* automation.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = automation.mm; sourceTree = "<group>"; }; BC11A5BD2608D58F0017BAD0 /* automation.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = automation.mm; sourceTree = "<group>"; };
ED3791C32862E1F40080BD62 /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -108,6 +110,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
ED3791C42862E1F40080BD62 /* UniformTypeIdentifiers.framework in Frameworks */,
1A3E5EB023E9FE8300EDE661 /* QuartzCore.framework in Frameworks */, 1A3E5EB023E9FE8300EDE661 /* QuartzCore.framework in Frameworks */,
1A3E5EAA23E9F26C00EDE661 /* IOSurface.framework in Frameworks */, 1A3E5EAA23E9F26C00EDE661 /* IOSurface.framework in Frameworks */,
AB1E522C217613570091CD71 /* OpenGL.framework in Frameworks */, AB1E522C217613570091CD71 /* OpenGL.framework in Frameworks */,
@ -122,6 +125,7 @@
AB661C1C2148230E00291242 /* Frameworks */ = { AB661C1C2148230E00291242 /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
ED3791C32862E1F40080BD62 /* UniformTypeIdentifiers.framework */,
522D5958258159C1006F7F7A /* Carbon.framework */, 522D5958258159C1006F7F7A /* Carbon.framework */,
1A3E5EAF23E9FE8300EDE661 /* QuartzCore.framework */, 1A3E5EAF23E9FE8300EDE661 /* QuartzCore.framework */,
1A3E5EA923E9F26C00EDE661 /* IOSurface.framework */, 1A3E5EA923E9F26C00EDE661 /* IOSurface.framework */,

52
native/Avalonia.Native/src/OSX/SystemDialogs.mm

@ -1,5 +1,6 @@
#include "common.h" #include "common.h"
#include "INSWindowHolder.h" #include "INSWindowHolder.h"
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
class SystemDialogs : public ComSingleObject<IAvnSystemDialogs, &IID_IAvnSystemDialogs> class SystemDialogs : public ComSingleObject<IAvnSystemDialogs, &IID_IAvnSystemDialogs>
{ {
@ -7,6 +8,7 @@ public:
FORWARD_IUNKNOWN() FORWARD_IUNKNOWN()
virtual void SelectFolderDialog (IAvnWindow* parentWindowHandle, virtual void SelectFolderDialog (IAvnWindow* parentWindowHandle,
IAvnSystemDialogEvents* events, IAvnSystemDialogEvents* events,
bool allowMultiple,
const char* title, const char* title,
const char* initialDirectory) override const char* initialDirectory) override
{ {
@ -14,6 +16,7 @@ public:
{ {
auto panel = [NSOpenPanel openPanel]; auto panel = [NSOpenPanel openPanel];
panel.allowsMultipleSelection = allowMultiple;
panel.canChooseDirectories = true; panel.canChooseDirectories = true;
panel.canCreateDirectories = true; panel.canCreateDirectories = true;
panel.canChooseFiles = false; panel.canChooseFiles = false;
@ -118,7 +121,15 @@ public:
{ {
auto allowedTypes = [filtersString componentsSeparatedByString:@";"]; auto allowedTypes = [filtersString componentsSeparatedByString:@";"];
panel.allowedFileTypes = allowedTypes; // Prefer allowedContentTypes if available
if (@available(macOS 11.0, *))
{
panel.allowedContentTypes = ConvertToUTType(allowedTypes);
}
else
{
panel.allowedFileTypes = allowedTypes;
}
} }
} }
@ -207,7 +218,18 @@ public:
{ {
auto allowedTypes = [filtersString componentsSeparatedByString:@";"]; auto allowedTypes = [filtersString componentsSeparatedByString:@";"];
panel.allowedFileTypes = allowedTypes; // Prefer allowedContentTypes if available
if (@available(macOS 11.0, *))
{
panel.allowedContentTypes = ConvertToUTType(allowedTypes);
}
else
{
panel.allowedFileTypes = allowedTypes;
}
panel.allowsOtherFileTypes = false;
panel.extensionHidden = false;
} }
} }
@ -250,6 +272,32 @@ public:
} }
} }
} }
private:
NSMutableArray* ConvertToUTType(NSArray<NSString*>* allowedTypes)
{
auto originalCount = [allowedTypes count];
auto mapped = [[NSMutableArray alloc] init];
if (@available(macOS 11.0, *))
{
for (int i = 0; i < originalCount; i++)
{
auto utTypeStr = allowedTypes[i];
auto utType = [UTType typeWithIdentifier:utTypeStr];
if (utType == nil)
{
utType = [UTType typeWithMIMEType:utTypeStr];
}
if (utType != nil)
{
[mapped addObject:utType];
}
}
}
return mapped;
}
}; };

2
samples/ControlCatalog/ControlCatalog.csproj

@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework> <TargetFrameworks>netstandard2.0;net6.0</TargetFrameworks>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

3
samples/ControlCatalog/MainView.xaml

@ -69,6 +69,9 @@
<TabItem Header="CalendarDatePicker"> <TabItem Header="CalendarDatePicker">
<pages:CalendarDatePickerPage /> <pages:CalendarDatePickerPage />
</TabItem> </TabItem>
<TabItem Header="Dialogs">
<pages:DialogsPage />
</TabItem>
<TabItem Header="Drag+Drop"> <TabItem Header="Drag+Drop">
<pages:DragAndDropPage /> <pages:DragAndDropPage />
</TabItem> </TabItem>

5
samples/ControlCatalog/MainView.xaml.cs

@ -24,11 +24,6 @@ namespace ControlCatalog
{ {
IList tabItems = ((IList)sideBar.Items); IList tabItems = ((IList)sideBar.Items);
tabItems.Add(new TabItem() tabItems.Add(new TabItem()
{
Header = "Dialogs",
Content = new DialogsPage()
});
tabItems.Add(new TabItem()
{ {
Header = "Screens", Header = "Screens",
Content = new ScreenPage() Content = new ScreenPage()

76
samples/ControlCatalog/Pages/DialogsPage.xaml

@ -1,29 +1,57 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl x:Class="ControlCatalog.Pages.DialogsPage"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns="https://github.com/avaloniaui"
x:Class="ControlCatalog.Pages.DialogsPage"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StackPanel Orientation="Vertical" Spacing="4" Margin="4"> <StackPanel Margin="4"
<CheckBox Name="UseFilters">Use filters</CheckBox> Orientation="Vertical"
<Button Name="OpenFile">_Open File</Button> Spacing="4">
<Button Name="OpenMultipleFiles">Open _Multiple File</Button>
<Button Name="SaveFile">_Save File</Button>
<Button Name="SelectFolder">Select Fo_lder</Button>
<Button Name="OpenBoth">Select _Both</Button>
<TextBlock x:Name="PickerLastResultsVisible" <TextBlock Text="Windows:" />
Classes="h2"
IsVisible="False"
Text="Last picker results:" />
<ItemsPresenter x:Name="PickerLastResults" />
<TextBlock Margin="0, 8, 0, 0" <Expander Header="Window dialogs">
Classes="h1" <StackPanel Spacing="4">
Text="Window dialogs" /> <Button Name="DecoratedWindow">Decorated _window</Button>
<Button Name="DecoratedWindow">Decorated _window</Button> <Button Name="DecoratedWindowDialog">Decorated w_indow (dialog)</Button>
<Button Name="DecoratedWindowDialog">Decorated w_indow (dialog)</Button> <Button Name="Dialog" ToolTip.Tip="Shows a dialog">_Dialog</Button>
<Button Name="Dialog" ToolTip.Tip="Shows a dialog">_Dialog</Button> <Button Name="DialogNoTaskbar">Dialog (_No taskbar icon)</Button>
<Button Name="DialogNoTaskbar">Dialog (_No taskbar icon)</Button> <Button Name="OwnedWindow">Own_ed window</Button>
<Button Name="OwnedWindow">Own_ed window</Button> <Button Name="OwnedWindowNoTaskbar">Owned window (No tas_kbar icon)</Button>
<Button Name="OwnedWindowNoTaskbar">Owned window (No tas_kbar icon)</Button> </StackPanel>
</Expander>
<TextBlock Margin="0,20,0,0" Text="Pickers:" />
<CheckBox Name="UseFilters">Use filters</CheckBox>
<Expander Header="FilePicker API">
<StackPanel Spacing="4">
<CheckBox Name="ForceManaged">Force managed dialog</CheckBox>
<CheckBox Name="OpenMultiple">Open multiple</CheckBox>
<Button Name="OpenFolderPicker">Select Fo_lder</Button>
<Button Name="OpenFilePicker">_Open File</Button>
<Button Name="SaveFilePicker">_Save File</Button>
<Button Name="OpenFileFromBookmark">Open File Bookmark</Button>
<Button Name="OpenFolderFromBookmark">Open Folder Bookmark</Button>
</StackPanel>
</Expander>
<Expander Header="Legacy OpenFileDialog">
<StackPanel Spacing="4">
<Button Name="OpenFile">_Open File</Button>
<Button Name="OpenMultipleFiles">Open _Multiple File</Button>
<Button Name="SaveFile">_Save File</Button>
<Button Name="SelectFolder">Select Fo_lder</Button>
<Button Name="OpenBoth">Select _Both</Button>
</StackPanel>
</Expander>
<TextBlock x:Name="PickerLastResultsVisible"
Classes="h2"
IsVisible="False"
Text="Last picker results:" />
<ItemsPresenter x:Name="PickerLastResults" />
<TextBox Name="BookmarkContainer" Watermark="Bookmark" />
<TextBox Name="OpenedFileContent"
MaxLines="10"
Watermark="Picked file content" />
</StackPanel> </StackPanel>
</UserControl> </UserControl>

234
samples/ControlCatalog/Pages/DialogsPage.xaml.cs

@ -1,13 +1,21 @@
using System; using System;
using System.Buffers;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Presenters; using Avalonia.Controls.Presenters;
using Avalonia.Dialogs; using Avalonia.Dialogs;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
#pragma warning disable 4014 using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
#pragma warning disable CS0618 // Type or member is obsolete
#nullable enable
namespace ControlCatalog.Pages namespace ControlCatalog.Pages
{ {
public class DialogsPage : UserControl public class DialogsPage : UserControl
@ -18,13 +26,16 @@ namespace ControlCatalog.Pages
var results = this.Get<ItemsPresenter>("PickerLastResults"); var results = this.Get<ItemsPresenter>("PickerLastResults");
var resultsVisible = this.Get<TextBlock>("PickerLastResultsVisible"); var resultsVisible = this.Get<TextBlock>("PickerLastResultsVisible");
var bookmarkContainer = this.Get<TextBox>("BookmarkContainer");
var openedFileContent = this.Get<TextBox>("OpenedFileContent");
var openMultiple = this.Get<CheckBox>("OpenMultiple");
string? lastSelectedDirectory = null; IStorageFolder? lastSelectedDirectory = null;
List<FileDialogFilter>? GetFilters() List<FileDialogFilter> GetFilters()
{ {
if (this.Get<CheckBox>("UseFilters").IsChecked != true) if (this.Get<CheckBox>("UseFilters").IsChecked != true)
return null; return new List<FileDialogFilter>();
return new List<FileDialogFilter> return new List<FileDialogFilter>
{ {
new FileDialogFilter new FileDialogFilter
@ -39,12 +50,23 @@ namespace ControlCatalog.Pages
}; };
} }
List<FilePickerFileType>? GetFileTypes()
{
if (this.Get<CheckBox>("UseFilters").IsChecked != true)
return null;
return new List<FilePickerFileType>
{
FilePickerFileTypes.All,
FilePickerFileTypes.TextPlain
};
}
this.Get<Button>("OpenFile").Click += async delegate this.Get<Button>("OpenFile").Click += async delegate
{ {
// Almost guaranteed to exist // Almost guaranteed to exist
var fullPath = Assembly.GetEntryAssembly()?.GetModules().FirstOrDefault()?.FullyQualifiedName; var uri = Assembly.GetEntryAssembly()?.GetModules().FirstOrDefault()?.FullyQualifiedName;
var initialFileName = fullPath == null ? null : System.IO.Path.GetFileName(fullPath); var initialFileName = uri == null ? null : System.IO.Path.GetFileName(uri);
var initialDirectory = fullPath == null ? null : System.IO.Path.GetDirectoryName(fullPath); var initialDirectory = uri == null ? null : System.IO.Path.GetDirectoryName(uri);
var result = await new OpenFileDialog() var result = await new OpenFileDialog()
{ {
@ -62,7 +84,7 @@ namespace ControlCatalog.Pages
{ {
Title = "Open multiple files", Title = "Open multiple files",
Filters = GetFilters(), Filters = GetFilters(),
Directory = lastSelectedDirectory, Directory = lastSelectedDirectory?.TryGetUri(out var path) == true ? path.LocalPath : null,
AllowMultiple = true AllowMultiple = true
}.ShowAsync(GetWindow()); }.ShowAsync(GetWindow());
results.Items = result; results.Items = result;
@ -70,11 +92,13 @@ namespace ControlCatalog.Pages
}; };
this.Get<Button>("SaveFile").Click += async delegate this.Get<Button>("SaveFile").Click += async delegate
{ {
var filters = GetFilters();
var result = await new SaveFileDialog() var result = await new SaveFileDialog()
{ {
Title = "Save file", Title = "Save file",
Filters = GetFilters(), Filters = filters,
Directory = lastSelectedDirectory, Directory = lastSelectedDirectory?.TryGetUri(out var path) == true ? path.LocalPath : null,
DefaultExtension = filters?.Any() == true ? "txt" : null,
InitialFileName = "test.txt" InitialFileName = "test.txt"
}.ShowAsync(GetWindow()); }.ShowAsync(GetWindow());
results.Items = new[] { result }; results.Items = new[] { result };
@ -85,14 +109,9 @@ namespace ControlCatalog.Pages
var result = await new OpenFolderDialog() var result = await new OpenFolderDialog()
{ {
Title = "Select folder", Title = "Select folder",
Directory = lastSelectedDirectory, Directory = lastSelectedDirectory?.TryGetUri(out var path) == true ? path.LocalPath : null
}.ShowAsync(GetWindow()); }.ShowAsync(GetWindow());
lastSelectedDirectory = new BclStorageFolder(new System.IO.DirectoryInfo(result));
if (!string.IsNullOrEmpty(result))
{
lastSelectedDirectory = result;
}
results.Items = new [] { result }; results.Items = new [] { result };
resultsVisible.IsVisible = result != null; resultsVisible.IsVisible = result != null;
}; };
@ -101,7 +120,7 @@ namespace ControlCatalog.Pages
var result = await new OpenFileDialog() var result = await new OpenFileDialog()
{ {
Title = "Select both", Title = "Select both",
Directory = lastSelectedDirectory, Directory = lastSelectedDirectory?.TryGetUri(out var path) == true ? path.LocalPath : null,
AllowMultiple = true AllowMultiple = true
}.ShowManagedAsync(GetWindow(), new ManagedFileDialogOptions }.ShowManagedAsync(GetWindow(), new ManagedFileDialogOptions
{ {
@ -116,20 +135,20 @@ namespace ControlCatalog.Pages
}; };
this.Get<Button>("DecoratedWindowDialog").Click += delegate this.Get<Button>("DecoratedWindowDialog").Click += delegate
{ {
new DecoratedWindow().ShowDialog(GetWindow()); _ = new DecoratedWindow().ShowDialog(GetWindow());
}; };
this.Get<Button>("Dialog").Click += delegate this.Get<Button>("Dialog").Click += delegate
{ {
var window = CreateSampleWindow(); var window = CreateSampleWindow();
window.Height = 200; window.Height = 200;
window.ShowDialog(GetWindow()); _ = window.ShowDialog(GetWindow());
}; };
this.Get<Button>("DialogNoTaskbar").Click += delegate this.Get<Button>("DialogNoTaskbar").Click += delegate
{ {
var window = CreateSampleWindow(); var window = CreateSampleWindow();
window.Height = 200; window.Height = 200;
window.ShowInTaskbar = false; window.ShowInTaskbar = false;
window.ShowDialog(GetWindow()); _ = window.ShowDialog(GetWindow());
}; };
this.Get<Button>("OwnedWindow").Click += delegate this.Get<Button>("OwnedWindow").Click += delegate
{ {
@ -146,13 +165,166 @@ namespace ControlCatalog.Pages
window.Show(GetWindow()); window.Show(GetWindow());
}; };
this.Get<Button>("OpenFilePicker").Click += async delegate
{
var result = await GetStorageProvider().OpenFilePickerAsync(new FilePickerOpenOptions()
{
Title = "Open file",
FileTypeFilter = GetFileTypes(),
SuggestedStartLocation = lastSelectedDirectory,
AllowMultiple = openMultiple.IsChecked == true
});
await SetPickerResult(result);
};
this.Get<Button>("SaveFilePicker").Click += async delegate
{
var fileTypes = GetFileTypes();
var file = await GetStorageProvider().SaveFilePickerAsync(new FilePickerSaveOptions()
{
Title = "Save file",
FileTypeChoices = fileTypes,
SuggestedStartLocation = lastSelectedDirectory,
SuggestedFileName = "FileName",
DefaultExtension = fileTypes?.Any() == true ? "txt" : null,
ShowOverwritePrompt = false
});
if (file is not null && file.CanOpenWrite)
{
// Sync disposal of StreamWriter is not supported on WASM
#if NET6_0_OR_GREATER
await using var stream = await file.OpenWrite();
await using var reader = new System.IO.StreamWriter(stream);
#else
using var stream = await file.OpenWrite();
using var reader = new System.IO.StreamWriter(stream);
#endif
await reader.WriteLineAsync(openedFileContent.Text);
lastSelectedDirectory = await file.GetParentAsync();
}
await SetPickerResult(file is null ? null : new [] {file});
};
this.Get<Button>("OpenFolderPicker").Click += async delegate
{
var folders = await GetStorageProvider().OpenFolderPickerAsync(new FolderPickerOpenOptions()
{
Title = "Folder file",
SuggestedStartLocation = lastSelectedDirectory,
AllowMultiple = openMultiple.IsChecked == true
});
await SetPickerResult(folders);
lastSelectedDirectory = folders.FirstOrDefault();
};
this.Get<Button>("OpenFileFromBookmark").Click += async delegate
{
var file = bookmarkContainer.Text is not null
? await GetStorageProvider().OpenFileBookmarkAsync(bookmarkContainer.Text)
: null;
await SetPickerResult(file is null ? null : new[] { file });
};
this.Get<Button>("OpenFolderFromBookmark").Click += async delegate
{
var folder = bookmarkContainer.Text is not null
? await GetStorageProvider().OpenFolderBookmarkAsync(bookmarkContainer.Text)
: null;
await SetPickerResult(folder is null ? null : new[] { folder });
lastSelectedDirectory = folder;
};
async Task SetPickerResult(IReadOnlyCollection<IStorageItem>? items)
{
items ??= Array.Empty<IStorageItem>();
var mappedResults = items.Select(FullPathOrName).ToList();
bookmarkContainer.Text = items.FirstOrDefault(f => f.CanBookmark) is { } f ? await f.SaveBookmark() : "Can't bookmark";
if (items.FirstOrDefault() is IStorageItem item)
{
var resultText = item is IStorageFile ? "File:" : "Folder:";
resultText += Environment.NewLine;
var props = await item.GetBasicPropertiesAsync();
resultText += @$"Size: {props.Size}
DateCreated: {props.DateCreated}
DateModified: {props.DateModified}
CanBookmark: {item.CanBookmark}
";
if (item is IStorageFile file)
{
resultText += @$"
CanOpenRead: {file.CanOpenRead}
CanOpenWrite: {file.CanOpenWrite}
Content:
";
if (file.CanOpenRead)
{
#if NET6_0_OR_GREATER
await using var stream = await file.OpenRead();
#else
using var stream = await file.OpenRead();
#endif
using var reader = new System.IO.StreamReader(stream);
// 4GB file test, shouldn't load more than 10000 chars into a memory.
const int length = 10000;
var buffer = ArrayPool<char>.Shared.Rent(length);
try
{
var charsRead = await reader.ReadAsync(buffer, 0, length);
resultText += new string(buffer, 0, charsRead);
}
finally
{
ArrayPool<char>.Shared.Return(buffer);
}
}
}
openedFileContent.Text = resultText;
lastSelectedDirectory = await item.GetParentAsync();
if (lastSelectedDirectory is not null)
{
mappedResults.Insert(0, "Parent: " + FullPathOrName(lastSelectedDirectory));
}
}
results.Items = mappedResults;
resultsVisible.IsVisible = mappedResults.Any();
}
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
var openedFileContent = this.Get<TextBox>("OpenedFileContent");
try
{
var storageProvider = GetStorageProvider();
openedFileContent.Text = $@"CanOpen: {storageProvider.CanOpen}
CanSave: {storageProvider.CanSave}
CanPickFolder: {storageProvider.CanPickFolder}";
}
catch (Exception ex)
{
openedFileContent.Text = "Storage provider is not available: " + ex.Message;
}
} }
private Window CreateSampleWindow() private Window CreateSampleWindow()
{ {
Button button; Button button;
Button dialogButton; Button dialogButton;
var window = new Window var window = new Window
{ {
Height = 200, Height = 200,
@ -191,7 +363,22 @@ namespace ControlCatalog.Pages
return window; return window;
} }
Window GetWindow() => this.VisualRoot as Window ?? throw new NullReferenceException("Invalid Owner"); private IStorageProvider GetStorageProvider()
{
var forceManaged = this.Get<CheckBox>("ForceManaged").IsChecked ?? false;
return forceManaged
? new ManagedStorageProvider<Window>(GetWindow(), null)
: GetTopLevel().StorageProvider;
}
private static string FullPathOrName(IStorageItem? item)
{
if (item is null) return "(null)";
return item.TryGetUri(out var uri) ? uri.ToString() : item.Name;
}
Window GetWindow() => this.VisualRoot as Window ?? throw new NullReferenceException("Invalid Owner");
TopLevel GetTopLevel() => this.VisualRoot as TopLevel ?? throw new NullReferenceException("Invalid Owner");
private void InitializeComponent() private void InitializeComponent()
{ {
@ -199,3 +386,4 @@ namespace ControlCatalog.Pages
} }
} }
} }
#pragma warning restore CS0618 // Type or member is obsolete

6
samples/ControlCatalog/Pages/NumericUpDownPage.xaml

@ -76,21 +76,21 @@
<StackPanel Orientation="Vertical" Margin="10"> <StackPanel Orientation="Vertical" Margin="10">
<Label Target="upDown" FontSize="14" FontWeight="Bold" VerticalAlignment="Center">Usage of decimal NumericUpDown:</Label> <Label Target="upDown" FontSize="14" FontWeight="Bold" VerticalAlignment="Center">Usage of decimal NumericUpDown:</Label>
<NumericUpDown Name="upDown" Minimum="0" Maximum="10" Increment="0.5" <NumericUpDown Name="upDown" Minimum="0" Maximum="10" Increment="0.5"
CultureInfo="en-US" VerticalAlignment="Center" Value="{Binding DecimalValue}" VerticalAlignment="Center" Value="{Binding DecimalValue}"
Watermark="Enter text" FormatString="{Binding SelectedFormat.Value}"/> Watermark="Enter text" FormatString="{Binding SelectedFormat.Value}"/>
</StackPanel> </StackPanel>
<StackPanel Orientation="Vertical" Margin="10"> <StackPanel Orientation="Vertical" Margin="10">
<Label Target="DoubleUpDown" FontSize="14" FontWeight="Bold" VerticalAlignment="Center">Usage of double NumericUpDown:</Label> <Label Target="DoubleUpDown" FontSize="14" FontWeight="Bold" VerticalAlignment="Center">Usage of double NumericUpDown:</Label>
<NumericUpDown Name="DoubleUpDown" Minimum="0" Maximum="10" Increment="0.5" <NumericUpDown Name="DoubleUpDown" Minimum="0" Maximum="10" Increment="0.5"
CultureInfo="en-US" VerticalAlignment="Center" Value="{Binding DoubleValue}" VerticalAlignment="Center" Value="{Binding DoubleValue}"
Watermark="Enter text" FormatString="{Binding SelectedFormat.Value}"/> Watermark="Enter text" FormatString="{Binding SelectedFormat.Value}"/>
</StackPanel> </StackPanel>
<StackPanel Orientation="Vertical" Margin="10"> <StackPanel Orientation="Vertical" Margin="10">
<Label Target="ValidationUpDown" FontSize="14" FontWeight="Bold" VerticalAlignment="Center">NumericUpDown with Validation Errors:</Label> <Label Target="ValidationUpDown" FontSize="14" FontWeight="Bold" VerticalAlignment="Center">NumericUpDown with Validation Errors:</Label>
<NumericUpDown x:Name="ValidationUpDown" Minimum="0" Maximum="10" Increment="0.5" <NumericUpDown x:Name="ValidationUpDown" Minimum="0" Maximum="10" Increment="0.5"
CultureInfo="en-US" VerticalAlignment="Center" VerticalAlignment="Center"
Watermark="Enter text" FormatString="{Binding SelectedFormat.Value}"> Watermark="Enter text" FormatString="{Binding SelectedFormat.Value}">
<DataValidationErrors.Error> <DataValidationErrors.Error>
<sys:Exception /> <sys:Exception />

13
samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs

@ -84,15 +84,10 @@ namespace ControlCatalog.Pages
} }
} }
public IList<CultureInfo> Cultures { get; } = new List<CultureInfo>() // Trimmed-mode friendly where we might not have cultures
{ public IList<CultureInfo?> Cultures { get; } = CultureInfo.GetCultures(CultureTypes.SpecificCultures)
new CultureInfo("en-US"), .Where(c => new[] { "en-US", "en-GB", "fr-FR", "ar-DZ", "zh-CH", "cs-CZ" }.Contains(c.Name))
new CultureInfo("en-GB"), .ToArray();
new CultureInfo("fr-FR"),
new CultureInfo("ar-DZ"),
new CultureInfo("zh-CN"),
new CultureInfo("cs-CZ")
};
public FormatObject SelectedFormat public FormatObject SelectedFormat
{ {

3
src/Android/Avalonia.Android/AndroidPlatform.cs

@ -1,10 +1,8 @@
using System; using System;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Android; using Avalonia.Android;
using Avalonia.Android.Platform; using Avalonia.Android.Platform;
using Avalonia.Android.Platform.Input; using Avalonia.Android.Platform.Input;
using Avalonia.Controls.Platform;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Input.Platform; using Avalonia.Input.Platform;
using Avalonia.OpenGL.Egl; using Avalonia.OpenGL.Egl;
@ -55,7 +53,6 @@ namespace Avalonia.Android
.Bind<IKeyboardDevice>().ToSingleton<AndroidKeyboardDevice>() .Bind<IKeyboardDevice>().ToSingleton<AndroidKeyboardDevice>()
.Bind<IPlatformSettings>().ToConstant(Instance) .Bind<IPlatformSettings>().ToConstant(Instance)
.Bind<IPlatformThreadingInterface>().ToConstant(new AndroidThreadingInterface()) .Bind<IPlatformThreadingInterface>().ToConstant(new AndroidThreadingInterface())
.Bind<ISystemDialogImpl>().ToTransient<SystemDialogImpl>()
.Bind<IPlatformIconLoader>().ToSingleton<PlatformIconLoaderStub>() .Bind<IPlatformIconLoader>().ToSingleton<PlatformIconLoaderStub>()
.Bind<IRenderTimer>().ToConstant(new ChoreographerTimer()) .Bind<IRenderTimer>().ToConstant(new ChoreographerTimer())
.Bind<IRenderLoop>().ToConstant(new RenderLoop()) .Bind<IRenderLoop>().ToConstant(new RenderLoop())

32
src/Android/Avalonia.Android/AvaloniaActivity.cs

@ -4,10 +4,14 @@ using Android.Content.Res;
using AndroidX.Lifecycle; using AndroidX.Lifecycle;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Controls; using Avalonia.Controls;
using Android.Runtime;
using Android.App;
using Android.Content;
using System;
namespace Avalonia.Android namespace Avalonia.Android
{ {
public abstract class AvaloniaActivity<TApp> : AppCompatActivity where TApp : Application, new() public abstract class AvaloniaActivity : AppCompatActivity
{ {
internal class SingleViewLifetime : ISingleViewApplicationLifetime internal class SingleViewLifetime : ISingleViewApplicationLifetime
{ {
@ -20,16 +24,15 @@ namespace Avalonia.Android
} }
} }
internal Action<int, Result, Intent> ActivityResult;
internal AvaloniaView View; internal AvaloniaView View;
internal AvaloniaViewModel _viewModel; internal AvaloniaViewModel _viewModel;
protected virtual AppBuilder CustomizeAppBuilder(AppBuilder builder) => builder.UseAndroid(); protected abstract AppBuilder CreateAppBuilder();
protected override void OnCreate(Bundle savedInstanceState) protected override void OnCreate(Bundle savedInstanceState)
{ {
var builder = AppBuilder.Configure<TApp>(); var builder = CreateAppBuilder();
CustomizeAppBuilder(builder);
var lifetime = new SingleViewLifetime(); var lifetime = new SingleViewLifetime();
@ -79,5 +82,24 @@ namespace Avalonia.Android
base.OnDestroy(); base.OnDestroy();
} }
protected override void OnActivityResult(int requestCode, [GeneratedEnum] Result resultCode, Intent data)
{
base.OnActivityResult(requestCode, resultCode, data);
ActivityResult?.Invoke(requestCode, resultCode, data);
}
}
public abstract class AvaloniaActivity<TApp> : AvaloniaActivity where TApp : Application, new()
{
protected virtual AppBuilder CustomizeAppBuilder(AppBuilder builder) => builder.UseAndroid();
protected override AppBuilder CreateAppBuilder()
{
var builder = AppBuilder.Configure<TApp>();
return CustomizeAppBuilder(builder);
}
} }
} }

8
src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs

@ -7,6 +7,7 @@ using Android.Views.InputMethods;
using Avalonia.Android.OpenGL; using Avalonia.Android.OpenGL;
using Avalonia.Android.Platform.Specific; using Avalonia.Android.Platform.Specific;
using Avalonia.Android.Platform.Specific.Helpers; using Avalonia.Android.Platform.Specific.Helpers;
using Avalonia.Android.Platform.Storage;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Platform; using Avalonia.Controls.Platform;
using Avalonia.Controls.Platform.Surfaces; using Avalonia.Controls.Platform.Surfaces;
@ -16,11 +17,13 @@ using Avalonia.Input.TextInput;
using Avalonia.OpenGL.Egl; using Avalonia.OpenGL.Egl;
using Avalonia.OpenGL.Surfaces; using Avalonia.OpenGL.Surfaces;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Rendering; using Avalonia.Rendering;
namespace Avalonia.Android.Platform.SkiaPlatform namespace Avalonia.Android.Platform.SkiaPlatform
{ {
class TopLevelImpl : IAndroidView, ITopLevelImpl, EglGlPlatformSurfaceBase.IEglWindowGlPlatformSurfaceInfo, ITopLevelImplWithTextInputMethod, ITopLevelImplWithNativeControlHost class TopLevelImpl : IAndroidView, ITopLevelImpl, EglGlPlatformSurfaceBase.IEglWindowGlPlatformSurfaceInfo,
ITopLevelImplWithTextInputMethod, ITopLevelImplWithNativeControlHost, ITopLevelImplWithStorageProvider
{ {
private readonly IGlPlatformSurface _gl; private readonly IGlPlatformSurface _gl;
private readonly IFramebufferPlatformSurface _framebuffer; private readonly IFramebufferPlatformSurface _framebuffer;
@ -46,6 +49,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform
_view.Resources.DisplayMetrics.HeightPixels).ToSize(RenderScaling); _view.Resources.DisplayMetrics.HeightPixels).ToSize(RenderScaling);
NativeControlHost = new AndroidNativeControlHostImpl(avaloniaView); NativeControlHost = new AndroidNativeControlHostImpl(avaloniaView);
StorageProvider = new AndroidStorageProvider((AvaloniaActivity)avaloniaView.Context);
} }
public virtual Point GetAvaloniaPointFromEvent(MotionEvent e, int pointerIndex) => public virtual Point GetAvaloniaPointFromEvent(MotionEvent e, int pointerIndex) =>
@ -225,6 +229,8 @@ namespace Avalonia.Android.Platform.SkiaPlatform
public ITextInputMethodImpl TextInputMethod => _textInputMethod; public ITextInputMethodImpl TextInputMethod => _textInputMethod;
public INativeControlHostImpl NativeControlHost { get; } public INativeControlHostImpl NativeControlHost { get; }
public IStorageProvider StorageProvider { get; }
public void SetTransparencyLevelHint(WindowTransparencyLevel transparencyLevel) public void SetTransparencyLevelHint(WindowTransparencyLevel transparencyLevel)
{ {

244
src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs

@ -0,0 +1,244 @@
#nullable enable
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Android.Content;
using Android.Provider;
using Avalonia.Logging;
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 abstract class AndroidStorageItem : IStorageBookmarkItem
{
private Context? _context;
protected AndroidStorageItem(Context context, AndroidUri uri)
{
_context = context;
Uri = uri;
}
internal AndroidUri Uri { get; }
protected Context Context => _context ?? throw new ObjectDisposedException(nameof(AndroidStorageItem));
public string Name => GetColumnValue(Context, Uri, MediaStore.IMediaColumns.DisplayName)
?? Uri.PathSegments?.LastOrDefault() ?? string.Empty;
public bool CanBookmark => true;
public Task<string?> SaveBookmark()
{
Context.ContentResolver?.TakePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission);
return Task.FromResult(Uri.ToString());
}
public Task ReleaseBookmark()
{
Context.ContentResolver?.ReleasePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission);
return Task.CompletedTask;
}
public bool TryGetUri([NotNullWhen(true)] out Uri? uri)
{
uri = new Uri(Uri.ToString()!);
return true;
}
public abstract Task<StorageItemProperties> GetBasicPropertiesAsync();
protected string? GetColumnValue(Context context, AndroidUri contentUri, string column, string? selection = null, string[]? selectionArgs = null)
{
try
{
var projection = new[] { column };
using var cursor = context.ContentResolver!.Query(contentUri, projection, selection, selectionArgs, null);
if (cursor?.MoveToFirst() == true)
{
var columnIndex = cursor.GetColumnIndex(column);
if (columnIndex != -1)
return cursor.GetString(columnIndex);
}
}
catch (Exception ex)
{
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "File metadata reader failed: '{Exception}'", ex);
}
return null;
}
public Task<IStorageFolder?> GetParentAsync()
{
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 Task.FromResult<IStorageFolder?>(null);
}
public void Dispose()
{
_context = null;
}
}
internal sealed class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
{
public AndroidStorageFolder(Context context, AndroidUri uri) : base(context, uri)
{
}
public override Task<StorageItemProperties> GetBasicPropertiesAsync()
{
return Task.FromResult(new StorageItemProperties());
}
}
internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkFile
{
public AndroidStorageFile(Context context, AndroidUri uri) : base(context, uri)
{
}
public bool CanOpenRead => true;
public bool CanOpenWrite => true;
public Task<Stream> OpenRead() => Task.FromResult(OpenContentStream(Context, Uri, false)
?? throw new InvalidOperationException("Failed to open content stream"));
public Task<Stream> OpenWrite() => Task.FromResult(OpenContentStream(Context, Uri, true)
?? throw new InvalidOperationException("Failed to open content stream"));
private Stream? OpenContentStream(Context context, AndroidUri uri, bool isOutput)
{
var isVirtual = IsVirtualFile(context, uri);
if (isVirtual)
{
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "Content URI was virtual: '{Uri}'", uri);
return GetVirtualFileStream(context, uri, isOutput);
}
return isOutput
? context.ContentResolver?.OpenOutputStream(uri)
: context.ContentResolver?.OpenInputStream(uri);
}
private bool IsVirtualFile(Context context, AndroidUri uri)
{
if (!DocumentsContract.IsDocumentUri(context, uri))
return false;
var value = GetColumnValue(context, uri, DocumentsContract.Document.ColumnFlags);
if (!string.IsNullOrEmpty(value) && int.TryParse(value, out var flagsInt))
{
var flags = (DocumentContractFlags)flagsInt;
return flags.HasFlag(DocumentContractFlags.VirtualDocument);
}
return false;
}
private Stream? GetVirtualFileStream(Context context, AndroidUri uri, bool isOutput)
{
var mimeTypes = context.ContentResolver?.GetStreamTypes(uri, FilePickerFileTypes.All.MimeTypes![0]);
if (mimeTypes?.Length >= 1)
{
var mimeType = mimeTypes[0];
var asset = context.ContentResolver!
.OpenTypedAssetFileDescriptor(uri, mimeType, null);
var stream = isOutput
? asset?.CreateOutputStream()
: asset?.CreateInputStream();
return stream;
}
return null;
}
public override Task<StorageItemProperties> GetBasicPropertiesAsync()
{
ulong? size = null;
DateTimeOffset? itemDate = null;
DateTimeOffset? dateModified = null;
try
{
var projection = new[]
{
MediaStore.IMediaColumns.Size, MediaStore.IMediaColumns.DateAdded,
MediaStore.IMediaColumns.DateModified
};
using var cursor = Context.ContentResolver!.Query(Uri, projection, null, null, null);
if (cursor?.MoveToFirst() == true)
{
try
{
var columnIndex = cursor.GetColumnIndex(MediaStore.IMediaColumns.Size);
if (columnIndex != -1)
{
size = (ulong)cursor.GetLong(columnIndex);
}
}
catch (Exception ex)
{
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?
.Log(this, "File Size metadata reader failed: '{Exception}'", ex);
}
try
{
var columnIndex = cursor.GetColumnIndex(MediaStore.IMediaColumns.DateAdded);
if (columnIndex != -1)
{
var longValue = cursor.GetLong(columnIndex);
itemDate = longValue > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(longValue) : null;
}
}
catch (Exception ex)
{
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?
.Log(this, "File DateAdded metadata reader failed: '{Exception}'", ex);
}
try
{
var columnIndex = cursor.GetColumnIndex(MediaStore.IMediaColumns.DateModified);
if (columnIndex != -1)
{
var longValue = cursor.GetLong(columnIndex);
dateModified = longValue > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(longValue) : null;
}
}
catch (Exception ex)
{
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?
.Log(this, "File DateAdded metadata reader failed: '{Exception}'", ex);
}
}
}
catch (UnsupportedOperationException)
{
// It's not possible to get parameters of some files/folders.
}
return Task.FromResult(new StorageItemProperties(size, itemDate, dateModified));
}
}

177
src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs

@ -0,0 +1,177 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Android.App;
using Android.Content;
using Android.Provider;
using Avalonia.Platform.Storage;
using AndroidUri = Android.Net.Uri;
namespace Avalonia.Android.Platform.Storage;
internal class AndroidStorageProvider : IStorageProvider
{
private readonly AvaloniaActivity _activity;
private int _lastRequestCode = 20000;
public AndroidStorageProvider(AvaloniaActivity activity)
{
_activity = activity;
}
public bool CanOpen => OperatingSystem.IsAndroidVersionAtLeast(19);
public bool CanSave => OperatingSystem.IsAndroidVersionAtLeast(19);
public bool CanPickFolder => OperatingSystem.IsAndroidVersionAtLeast(21);
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));
}
public Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark)
{
var uri = AndroidUri.Parse(bookmark) ?? throw new ArgumentException("Couldn't parse Bookmark value", nameof(bookmark));
return Task.FromResult<IStorageBookmarkFile?>(new AndroidStorageFile(_activity, uri));
}
public async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
{
var mimeTypes = options.FileTypeFilter?.Where(t => t != FilePickerFileTypes.All)
.SelectMany(f => f.MimeTypes ?? Array.Empty<string>()).Distinct().ToArray() ?? Array.Empty<string>();
var intent = new Intent(Intent.ActionOpenDocument)
.AddCategory(Intent.CategoryOpenable)
.PutExtra(Intent.ExtraAllowMultiple, options.AllowMultiple)
.SetType(FilePickerFileTypes.All.MimeTypes![0]);
if (mimeTypes.Length > 0)
{
intent = intent.PutExtra(Intent.ExtraMimeTypes, mimeTypes);
}
if (TryGetInitialUri(options.SuggestedStartLocation) is { } initialUri)
{
intent = intent.PutExtra(DocumentsContract.ExtraInitialUri, initialUri);
}
var pickerIntent = Intent.CreateChooser(intent, options.Title ?? "Select file");
var uris = await StartActivity(pickerIntent, false);
return uris.Select(u => new AndroidStorageFile(_activity, u)).ToArray();
}
public async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
{
var mimeTypes = options.FileTypeChoices?.Where(t => t != FilePickerFileTypes.All)
.SelectMany(f => f.MimeTypes ?? Array.Empty<string>()).Distinct().ToArray() ?? Array.Empty<string>();
var intent = new Intent(Intent.ActionCreateDocument)
.AddCategory(Intent.CategoryOpenable)
.SetType(FilePickerFileTypes.All.MimeTypes![0]);
if (mimeTypes.Length > 0)
{
intent = intent.PutExtra(Intent.ExtraMimeTypes, mimeTypes);
}
if (options.SuggestedFileName is { } fileName)
{
if (options.DefaultExtension is { } ext)
{
fileName += ext.StartsWith('.') ? ext : "." + ext;
}
intent = intent.PutExtra(Intent.ExtraTitle, fileName);
}
if (TryGetInitialUri(options.SuggestedStartLocation) is { } initialUri)
{
intent = intent.PutExtra(DocumentsContract.ExtraInitialUri, initialUri);
}
var pickerIntent = Intent.CreateChooser(intent, options.Title ?? "Save file");
var uris = await StartActivity(pickerIntent, true);
return uris.Select(u => new AndroidStorageFile(_activity, u)).FirstOrDefault();
}
public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{
var intent = new Intent(Intent.ActionOpenDocumentTree)
.PutExtra(Intent.ExtraAllowMultiple, options.AllowMultiple);
if (TryGetInitialUri(options.SuggestedStartLocation) is { } initialUri)
{
intent = intent.PutExtra(DocumentsContract.ExtraInitialUri, initialUri);
}
var pickerIntent = Intent.CreateChooser(intent, options.Title ?? "Select folder");
var uris = await StartActivity(pickerIntent, false);
return uris.Select(u => new AndroidStorageFolder(_activity, u)).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++;
_activity.ActivityResult += OnActivityResult;
_activity.StartActivityForResult(pickerIntent, currentRequestCode);
var result = await tcs.Task;
if (result != null)
{
// ClipData first to avoid issue with multiple files selection.
if (!singleResult && result.ClipData is { } clipData)
{
for (var i = 0; i < clipData.ItemCount; i++)
{
var uri = clipData.GetItemAt(i)?.Uri;
if (uri != null)
{
resultList.Add(uri);
}
}
}
else if (result.Data is { } uri)
{
resultList.Add(uri);
}
}
if (result?.HasExtra("error") == true)
{
throw new Exception(result.GetStringExtra("error"));
}
return resultList;
void OnActivityResult(int requestCode, Result resultCode, Intent data)
{
if (currentRequestCode != requestCode)
{
return;
}
_activity.ActivityResult -= OnActivityResult;
_ = tcs.TrySetResult(resultCode == Result.Ok ? data : null);
}
}
private static AndroidUri? TryGetInitialUri(IStorageFolder? folder)
{
if (OperatingSystem.IsAndroidVersionAtLeast(26)
&& (folder as AndroidStorageItem)?.Uri is { } uri)
{
return uri;
}
return null;
}
}

20
src/Android/Avalonia.Android/SystemDialogImpl.cs

@ -1,20 +0,0 @@
using System;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.Platform;
namespace Avalonia.Android
{
internal class SystemDialogImpl : ISystemDialogImpl
{
public Task<string[]> ShowFileDialogAsync(FileDialog dialog, Window parent)
{
throw new NotImplementedException();
}
public Task<string> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent)
{
throw new NotImplementedException();
}
}
}

10
src/Avalonia.Base/Logging/LogArea.cs

@ -44,5 +44,15 @@ namespace Avalonia.Logging
/// The log event comes from X11Platform. /// The log event comes from X11Platform.
/// </summary> /// </summary>
public const string X11Platform = nameof(X11Platform); public const string X11Platform = nameof(X11Platform);
/// <summary>
/// The log event comes from AndroidPlatform.
/// </summary>
public const string AndroidPlatform = nameof(AndroidPlatform);
/// <summary>
/// The log event comes from IOSPlatform.
/// </summary>
public const string IOSPlatform = nameof(IOSPlatform);
} }
} }

107
src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs

@ -0,0 +1,107 @@
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
{
private readonly FileInfo _fileInfo;
public BclStorageFile(FileInfo fileInfo)
{
_fileInfo = fileInfo ?? throw new ArgumentNullException(nameof(fileInfo));
}
public bool CanOpenRead => true;
public bool CanOpenWrite => true;
public string Name => _fileInfo.Name;
public virtual bool CanBookmark => true;
public Task<StorageItemProperties> GetBasicPropertiesAsync()
{
var props = new StorageItemProperties();
if (_fileInfo.Exists)
{
props = new StorageItemProperties(
(ulong)_fileInfo.Length,
_fileInfo.CreationTimeUtc,
_fileInfo.LastAccessTimeUtc);
}
return Task.FromResult(props);
}
public Task<IStorageFolder?> GetParentAsync()
{
if (_fileInfo.Directory is { } directory)
{
return Task.FromResult<IStorageFolder?>(new BclStorageFolder(directory));
}
return Task.FromResult<IStorageFolder?>(null);
}
public Task<Stream> OpenRead()
{
return Task.FromResult<Stream>(_fileInfo.OpenRead());
}
public Task<Stream> OpenWrite()
{
return Task.FromResult<Stream>(_fileInfo.OpenWrite());
}
public virtual Task<string?> SaveBookmark()
{
return Task.FromResult<string?>(_fileInfo.FullName);
}
public Task ReleaseBookmark()
{
// No-op
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)
{
}
~BclStorageFile()
{
Dispose(disposing: false);
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}

88
src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs

@ -0,0 +1,88 @@
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 BclStorageFolder : IStorageBookmarkFolder
{
private readonly DirectoryInfo _directoryInfo;
public BclStorageFolder(DirectoryInfo directoryInfo)
{
_directoryInfo = directoryInfo ?? throw new ArgumentNullException(nameof(directoryInfo));
if (!_directoryInfo.Exists)
{
throw new ArgumentException("Directory must exist", nameof(directoryInfo));
}
}
public string Name => _directoryInfo.Name;
public bool CanBookmark => true;
public Task<StorageItemProperties> GetBasicPropertiesAsync()
{
var props = new StorageItemProperties(
null,
_directoryInfo.CreationTimeUtc,
_directoryInfo.LastAccessTimeUtc);
return Task.FromResult(props);
}
public Task<IStorageFolder?> GetParentAsync()
{
if (_directoryInfo.Parent is { } directory)
{
return Task.FromResult<IStorageFolder?>(new BclStorageFolder(directory));
}
return Task.FromResult<IStorageFolder?>(null);
}
public virtual Task<string?> SaveBookmark()
{
return Task.FromResult<string?>(_directoryInfo.FullName);
}
public Task ReleaseBookmark()
{
// No-op
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)
{
}
~BclStorageFolder()
{
Dispose(disposing: false);
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}

35
src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs

@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Metadata;
namespace Avalonia.Platform.Storage.FileIO;
[Unstable]
public abstract class BclStorageProvider : IStorageProvider
{
public abstract bool CanOpen { get; }
public abstract Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options);
public abstract bool CanSave { get; }
public abstract Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options);
public abstract bool CanPickFolder { get; }
public abstract Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options);
public virtual Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark)
{
var file = new FileInfo(bookmark);
return file.Exists
? Task.FromResult<IStorageBookmarkFile?>(new BclStorageFile(file))
: Task.FromResult<IStorageBookmarkFile?>(null);
}
public virtual Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark)
{
var folder = new DirectoryInfo(bookmark);
return folder.Exists
? Task.FromResult<IStorageBookmarkFolder?>(new BclStorageFolder(folder))
: Task.FromResult<IStorageBookmarkFolder?>(null);
}
}

40
src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs

@ -0,0 +1,40 @@
using System;
using System.IO;
using System.Linq;
using Avalonia.Metadata;
namespace Avalonia.Platform.Storage.FileIO;
[Unstable]
public static class StorageProviderHelpers
{
public static string NameWithExtension(string path, string? defaultExtension, FilePickerFileType? filter)
{
var name = Path.GetFileName(path);
if (name != null && !Path.HasExtension(name))
{
if (filter?.Patterns?.Count > 0)
{
if (defaultExtension != null
&& filter.Patterns.Contains(defaultExtension))
{
return Path.ChangeExtension(path, defaultExtension.TrimStart('.'));
}
var ext = filter.Patterns.FirstOrDefault(x => x != "*.*");
ext = ext?.Split(new[] { "*." }, StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
if (ext != null)
{
return Path.ChangeExtension(path, ext);
}
}
if (defaultExtension != null)
{
return Path.ChangeExtension(path, defaultExtension);
}
}
return path;
}
}

44
src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs

@ -0,0 +1,44 @@
using System.Collections.Generic;
namespace Avalonia.Platform.Storage;
/// <summary>
/// Represents a name mapped to the associated file types (extensions).
/// </summary>
public sealed class FilePickerFileType
{
public FilePickerFileType(string name)
{
Name = name;
}
/// <summary>
/// File type name.
/// </summary>
public string Name { get; }
/// <summary>
/// List of extensions in GLOB format. I.e. "*.png" or "*.*".
/// </summary>
/// <remarks>
/// Used on Windows and Linux systems.
/// </remarks>
public IReadOnlyList<string>? Patterns { get; set; }
/// <summary>
/// List of extensions in MIME format.
/// </summary>
/// <remarks>
/// Used on Android, Browser and Linux systems.
/// </remarks>
public IReadOnlyList<string>? MimeTypes { get; set; }
/// <summary>
/// List of extensions in Apple uniform format.
/// </summary>
/// <remarks>
/// Used only on Apple devices.
/// See https://developer.apple.com/documentation/uniformtypeidentifiers/system_declared_uniform_type_identifiers.
/// </remarks>
public IReadOnlyList<string>? AppleUniformTypeIdentifiers { get; set; }
}

48
src/Avalonia.Base/Platform/Storage/FilePickerFileTypes.cs

@ -0,0 +1,48 @@
namespace Avalonia.Platform.Storage;
/// <summary>
/// Dictionary of well known file types.
/// </summary>
public static class FilePickerFileTypes
{
public static FilePickerFileType All { get; } = new("All")
{
Patterns = new[] { "*.*" },
MimeTypes = new[] { "*/*" }
};
public static FilePickerFileType TextPlain { get; } = new("Plain Text")
{
Patterns = new[] { "*.txt" },
AppleUniformTypeIdentifiers = new[] { "public.plain-text" },
MimeTypes = new[] { "text/plain" }
};
public static FilePickerFileType ImageAll { get; } = new("All Images")
{
Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp" },
AppleUniformTypeIdentifiers = new[] { "public.image" },
MimeTypes = new[] { "image/*" }
};
public static FilePickerFileType ImageJpg { get; } = new("JPEG image")
{
Patterns = new[] { "*.jpg", "*.jpeg" },
AppleUniformTypeIdentifiers = new[] { "public.jpeg" },
MimeTypes = new[] { "image/jpeg" }
};
public static FilePickerFileType ImagePng { get; } = new("PNG image")
{
Patterns = new[] { "*.png" },
AppleUniformTypeIdentifiers = new[] { "public.png" },
MimeTypes = new[] { "image/png" }
};
public static FilePickerFileType Pdf { get; } = new("PDF document")
{
Patterns = new[] { "*.pdf" },
AppleUniformTypeIdentifiers = new[] { "com.adobe.pdf" },
MimeTypes = new[] { "application/pdf" }
};
}

19
src/Avalonia.Base/Platform/Storage/FilePickerOpenOptions.cs

@ -0,0 +1,19 @@
using System.Collections.Generic;
namespace Avalonia.Platform.Storage;
/// <summary>
/// Options class for <see cref="IStorageProvider.OpenFilePickerAsync"/> method.
/// </summary>
public class FilePickerOpenOptions : PickerOptions
{
/// <summary>
/// Gets or sets an option indicating whether open picker allows users to select multiple files.
/// </summary>
public bool AllowMultiple { get; set; }
/// <summary>
/// Gets or sets the collection of file types that the file open picker displays.
/// </summary>
public IReadOnlyList<FilePickerFileType>? FileTypeFilter { get; set; }
}

29
src/Avalonia.Base/Platform/Storage/FilePickerSaveOptions.cs

@ -0,0 +1,29 @@
using System.Collections.Generic;
namespace Avalonia.Platform.Storage;
/// <summary>
/// Options class for <see cref="IStorageProvider.SaveFilePickerAsync"/> method.
/// </summary>
public class FilePickerSaveOptions : PickerOptions
{
/// <summary>
/// Gets or sets the file name that the file save picker suggests to the user.
/// </summary>
public string? SuggestedFileName { get; set; }
/// <summary>
/// Gets or sets the default extension to be used to save the file.
/// </summary>
public string? DefaultExtension { get; set; }
/// <summary>
/// Gets or sets the collection of valid file types that the user can choose to assign to a file.
/// </summary>
public IReadOnlyList<FilePickerFileType>? FileTypeChoices { get; set; }
/// <summary>
/// Gets or sets a value indicating whether file open picker displays a warning if the user specifies the name of a file that already exists.
/// </summary>
public bool? ShowOverwritePrompt { get; set; }
}

12
src/Avalonia.Base/Platform/Storage/FolderPickerOpenOptions.cs

@ -0,0 +1,12 @@
namespace Avalonia.Platform.Storage;
/// <summary>
/// Options class for <see cref="IStorageProvider.OpenFolderPickerAsync"/> method.
/// </summary>
public class FolderPickerOpenOptions : PickerOptions
{
/// <summary>
/// Gets or sets an option indicating whether open picker allows users to select multiple folders.
/// </summary>
public bool AllowMultiple { get; set; }
}

20
src/Avalonia.Base/Platform/Storage/IStorageBookmarkItem.cs

@ -0,0 +1,20 @@
using System.Threading.Tasks;
using Avalonia.Metadata;
namespace Avalonia.Platform.Storage;
[NotClientImplementable]
public interface IStorageBookmarkItem : IStorageItem
{
Task ReleaseBookmark();
}
[NotClientImplementable]
public interface IStorageBookmarkFile : IStorageFile, IStorageBookmarkItem
{
}
[NotClientImplementable]
public interface IStorageBookmarkFolder : IStorageFolder, IStorageBookmarkItem
{
}

32
src/Avalonia.Base/Platform/Storage/IStorageFile.cs

@ -0,0 +1,32 @@
using System.IO;
using System.Threading.Tasks;
using Avalonia.Metadata;
namespace Avalonia.Platform.Storage;
/// <summary>
/// Represents a file. Provides information about the file and its contents, and ways to manipulate them.
/// </summary>
[NotClientImplementable]
public interface IStorageFile : IStorageItem
{
/// <summary>
/// Returns true, if file is readable.
/// </summary>
bool CanOpenRead { get; }
/// <summary>
/// Opens a stream for read access.
/// </summary>
Task<Stream> OpenRead();
/// <summary>
/// Returns true, if file is writeable.
/// </summary>
bool CanOpenWrite { get; }
/// <summary>
/// Opens stream for writing to the file.
/// </summary>
Task<Stream> OpenWrite();
}

11
src/Avalonia.Base/Platform/Storage/IStorageFolder.cs

@ -0,0 +1,11 @@
using Avalonia.Metadata;
namespace Avalonia.Platform.Storage;
/// <summary>
/// Manipulates folders and their contents, and provides information about them.
/// </summary>
[NotClientImplementable]
public interface IStorageFolder : IStorageItem
{
}

53
src/Avalonia.Base/Platform/Storage/IStorageItem.cs

@ -0,0 +1,53 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Avalonia.Metadata;
namespace Avalonia.Platform.Storage;
/// <summary>
/// Manipulates storage items (files and folders) and their contents, and provides information about them
/// </summary>
/// <remarks>
/// This interface inherits <see cref="IDisposable"/> . It's recommended to dispose <see cref="IStorageItem"/> when it's not used anymore.
/// </remarks>
[NotClientImplementable]
public interface IStorageItem : IDisposable
{
/// <summary>
/// Gets the name of the item including the file name extension if there is one.
/// </summary>
string Name { get; }
/// <summary>
/// Gets the full file-system path of the item, if the item has a path.
/// </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);
/// <summary>
/// Gets the basic properties of the current item.
/// </summary>
Task<StorageItemProperties> GetBasicPropertiesAsync();
/// <summary>
/// Returns true is item can be bookmarked and reused later.
/// </summary>
bool CanBookmark { get; }
/// <summary>
/// Saves items to a bookmark.
/// </summary>
/// <returns>
/// Returns identifier of a bookmark. Can be null if OS denied request.
/// </returns>
Task<string?> SaveBookmark();
/// <summary>
/// Gets the parent folder of the current storage item.
/// </summary>
Task<IStorageFolder?> GetParentAsync();
}

56
src/Avalonia.Base/Platform/Storage/IStorageProvider.cs

@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Avalonia.Metadata;
namespace Avalonia.Platform.Storage;
[NotClientImplementable]
public interface IStorageProvider
{
/// <summary>
/// Returns true if it's possible to open file picker on the current platform.
/// </summary>
bool CanOpen { get; }
/// <summary>
/// Opens file picker dialog.
/// </summary>
/// <returns>Array of selected <see cref="IStorageFile"/> or empty collection if user canceled the dialog.</returns>
Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options);
/// <summary>
/// Returns true if it's possible to open save file picker on the current platform.
/// </summary>
bool CanSave { get; }
/// <summary>
/// Opens save file picker dialog.
/// </summary>
/// <returns>Saved <see cref="IStorageFile"/> or null if user canceled the dialog.</returns>
Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options);
/// <summary>
/// Returns true if it's possible to open folder picker on the current platform.
/// </summary>
bool CanPickFolder { get; }
/// <summary>
/// Opens folder picker dialog.
/// </summary>
/// <returns>Array of selected <see cref="IStorageFolder"/> or empty collection if user canceled the dialog.</returns>
Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options);
/// <summary>
/// Open <see cref="IStorageBookmarkFile"/> from the bookmark ID.
/// </summary>
/// <param name="bookmark">Bookmark ID.</param>
/// <returns>Bookmarked file or null if OS denied request.</returns>
Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark);
/// <summary>
/// Open <see cref="IStorageBookmarkFolder"/> from the bookmark ID.
/// </summary>
/// <param name="bookmark">Bookmark ID.</param>
/// <returns>Bookmarked folder or null if OS denied request.</returns>
Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark);
}

17
src/Avalonia.Base/Platform/Storage/PickerOptions.cs

@ -0,0 +1,17 @@
namespace Avalonia.Platform.Storage;
/// <summary>
/// Common options for <see cref="IStorageProvider.OpenFolderPickerAsync"/>, <see cref="IStorageProvider.OpenFilePickerAsync"/> and <see cref="IStorageProvider.SaveFilePickerAsync"/> methods.
/// </summary>
public class PickerOptions
{
/// <summary>
/// Gets or sets the text that appears in the title bar of a folder dialog.
/// </summary>
public string? Title { get; set; }
/// <summary>
/// Gets or sets the initial location where the file open picker looks for files to present to the user.
/// </summary>
public IStorageFolder? SuggestedStartLocation { get; set; }
}

43
src/Avalonia.Base/Platform/Storage/StorageItemProperties.cs

@ -0,0 +1,43 @@
using System;
namespace Avalonia.Platform.Storage;
/// <summary>
/// Provides access to the content-related properties of an item (like a file or folder).
/// </summary>
public class StorageItemProperties
{
public StorageItemProperties(
ulong? size = null,
DateTimeOffset? dateCreated = null,
DateTimeOffset? dateModified = null)
{
Size = size;
DateCreated = dateCreated;
DateModified = dateModified;
}
/// <summary>
/// Gets the size of the file in bytes.
/// </summary>
/// <remarks>
/// Can be null if property is not available.
/// </remarks>
public ulong? Size { get; }
/// <summary>
/// Gets the date and time that the current folder was created.
/// </summary>
/// <remarks>
/// Can be null if property is not available.
/// </remarks>
public DateTimeOffset? DateCreated { get; }
/// <summary>
/// Gets the date and time of the last time the file was modified.
/// </summary>
/// <remarks>
/// Can be null if property is not available.
/// </remarks>
public DateTimeOffset? DateModified { get; }
}

12
src/Avalonia.Controls/Platform/Dialogs/IStorageProviderFactory.cs

@ -0,0 +1,12 @@
#nullable enable
using Avalonia.Platform.Storage;
namespace Avalonia.Controls.Platform;
/// <summary>
/// Factory allows to register custom storage provider instead of native implementation.
/// </summary>
public interface IStorageProviderFactory
{
IStorageProvider CreateProvider(TopLevel topLevel);
}

2
src/Avalonia.Controls/Platform/ISystemDialogImpl.cs → src/Avalonia.Controls/Platform/Dialogs/ISystemDialogImpl.cs

@ -1,3 +1,4 @@
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Metadata; using Avalonia.Metadata;
@ -6,6 +7,7 @@ namespace Avalonia.Controls.Platform
/// <summary> /// <summary>
/// Defines a platform-specific system dialog implementation. /// Defines a platform-specific system dialog implementation.
/// </summary> /// </summary>
[Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")]
[Unstable] [Unstable]
public interface ISystemDialogImpl public interface ISystemDialogImpl
{ {

74
src/Avalonia.Controls/Platform/Dialogs/SystemDialogImpl.cs

@ -0,0 +1,74 @@
using System;
using System.Linq;
using System.Threading.Tasks;
#nullable enable
namespace Avalonia.Controls.Platform
{
/// <summary>
/// Defines a platform-specific system dialog implementation.
/// </summary>
[Obsolete]
internal class SystemDialogImpl : ISystemDialogImpl
{
public async Task<string[]?> ShowFileDialogAsync(FileDialog dialog, Window parent)
{
if (dialog is OpenFileDialog openDialog)
{
var filePicker = parent.StorageProvider;
if (!filePicker.CanOpen)
{
return null;
}
var options = openDialog.ToFilePickerOpenOptions();
var files = await filePicker.OpenFilePickerAsync(options);
return files
.Select(file => file.TryGetUri(out var fullPath)
? fullPath.LocalPath
: file.Name)
.ToArray();
}
else if (dialog is SaveFileDialog saveDialog)
{
var filePicker = parent.StorageProvider;
if (!filePicker.CanSave)
{
return null;
}
var options = saveDialog.ToFilePickerSaveOptions();
var file = await filePicker.SaveFilePickerAsync(options);
if (file is null)
{
return null;
}
var filePath = file.TryGetUri(out var fullPath)
? fullPath.LocalPath
: file.Name;
return new[] { filePath };
}
return null;
}
public async Task<string?> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent)
{
var filePicker = parent.StorageProvider;
if (!filePicker.CanPickFolder)
{
return null;
}
var options = dialog.ToFolderPickerOpenOptions();
var folders = await filePicker.OpenFolderPickerAsync(options);
return folders
.Select(f => f.TryGetUri(out var uri) ? uri.LocalPath : null)
.FirstOrDefault(u => u is not null);
}
}
}

11
src/Avalonia.Controls/Platform/ITopLevelImplWithStorageProvider.cs

@ -0,0 +1,11 @@
using Avalonia.Metadata;
using Avalonia.Platform;
using Avalonia.Platform.Storage;
namespace Avalonia.Controls.Platform;
[Unstable]
public interface ITopLevelImplWithStorageProvider : ITopLevelImpl
{
public IStorageProvider StorageProvider { get; }
}

57
src/Avalonia.Controls/SystemDialog.cs

@ -3,12 +3,15 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls.Platform; using Avalonia.Controls.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
namespace Avalonia.Controls namespace Avalonia.Controls
{ {
/// <summary> /// <summary>
/// Base class for system file dialogs. /// Base class for system file dialogs.
/// </summary> /// </summary>
[Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")]
public abstract class FileDialog : FileSystemDialog public abstract class FileDialog : FileSystemDialog
{ {
/// <summary> /// <summary>
@ -26,6 +29,7 @@ namespace Avalonia.Controls
/// <summary> /// <summary>
/// Base class for system file and directory dialogs. /// Base class for system file and directory dialogs.
/// </summary> /// </summary>
[Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")]
public abstract class FileSystemDialog : SystemDialog public abstract class FileSystemDialog : SystemDialog
{ {
[Obsolete("Use Directory")] [Obsolete("Use Directory")]
@ -45,6 +49,7 @@ namespace Avalonia.Controls
/// <summary> /// <summary>
/// Represents a system dialog that prompts the user to select a location for saving a file. /// Represents a system dialog that prompts the user to select a location for saving a file.
/// </summary> /// </summary>
[Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")]
public class SaveFileDialog : FileDialog public class SaveFileDialog : FileDialog
{ {
/// <summary> /// <summary>
@ -73,11 +78,27 @@ namespace Avalonia.Controls
return (await service.ShowFileDialogAsync(this, parent) ?? return (await service.ShowFileDialogAsync(this, parent) ??
Array.Empty<string>()).FirstOrDefault(); Array.Empty<string>()).FirstOrDefault();
} }
public FilePickerSaveOptions ToFilePickerSaveOptions()
{
return new FilePickerSaveOptions
{
SuggestedFileName = InitialFileName,
DefaultExtension = DefaultExtension,
FileTypeChoices = Filters?.Select(f => new FilePickerFileType(f.Name!) { Patterns = f.Extensions.Select(e => $"*.{e}").ToArray() }).ToArray(),
Title = Title,
SuggestedStartLocation = InitialDirectory is { } directory
? new BclStorageFolder(new System.IO.DirectoryInfo(directory))
: null,
ShowOverwritePrompt = ShowOverwritePrompt
};
}
} }
/// <summary> /// <summary>
/// Represents a system dialog that allows the user to select one or more files to open. /// Represents a system dialog that allows the user to select one or more files to open.
/// </summary> /// </summary>
[Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")]
public class OpenFileDialog : FileDialog public class OpenFileDialog : FileDialog
{ {
/// <summary> /// <summary>
@ -100,11 +121,25 @@ namespace Avalonia.Controls
var service = AvaloniaLocator.Current.GetRequiredService<ISystemDialogImpl>(); var service = AvaloniaLocator.Current.GetRequiredService<ISystemDialogImpl>();
return service.ShowFileDialogAsync(this, parent); return service.ShowFileDialogAsync(this, parent);
} }
public FilePickerOpenOptions ToFilePickerOpenOptions()
{
return new FilePickerOpenOptions
{
AllowMultiple = AllowMultiple,
FileTypeFilter = Filters?.Select(f => new FilePickerFileType(f.Name!) { Patterns = f.Extensions.Select(e => $"*.{e}").ToArray() }).ToArray(),
Title = Title,
SuggestedStartLocation = InitialDirectory is { } directory
? new BclStorageFolder(new System.IO.DirectoryInfo(directory))
: null
};
}
} }
/// <summary> /// <summary>
/// Represents a system dialog that allows the user to select a directory. /// Represents a system dialog that allows the user to select a directory.
/// </summary> /// </summary>
[Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")]
public class OpenFolderDialog : FileSystemDialog public class OpenFolderDialog : FileSystemDialog
{ {
[Obsolete("Use Directory")] [Obsolete("Use Directory")]
@ -129,14 +164,35 @@ namespace Avalonia.Controls
var service = AvaloniaLocator.Current.GetRequiredService<ISystemDialogImpl>(); var service = AvaloniaLocator.Current.GetRequiredService<ISystemDialogImpl>();
return service.ShowFolderDialogAsync(this, parent); return service.ShowFolderDialogAsync(this, parent);
} }
public FolderPickerOpenOptions ToFolderPickerOpenOptions()
{
return new FolderPickerOpenOptions
{
Title = Title,
SuggestedStartLocation = InitialDirectory is { } directory
? new BclStorageFolder(new System.IO.DirectoryInfo(directory))
: null
};
}
} }
/// <summary> /// <summary>
/// Base class for system dialogs. /// Base class for system dialogs.
/// </summary> /// </summary>
[Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")]
public abstract class SystemDialog public abstract class SystemDialog
{ {
static SystemDialog()
{
if (AvaloniaLocator.Current.GetService<ISystemDialogImpl>() is null)
{
// Register default implementation.
AvaloniaLocator.CurrentMutable.Bind<ISystemDialogImpl>().ToSingleton<SystemDialogImpl>();
}
}
/// <summary> /// <summary>
/// Gets or sets the dialog title. /// Gets or sets the dialog title.
/// </summary> /// </summary>
@ -146,6 +202,7 @@ namespace Avalonia.Controls
/// <summary> /// <summary>
/// Represents a filter in an <see cref="OpenFileDialog"/> or an <see cref="SaveFileDialog"/>. /// Represents a filter in an <see cref="OpenFileDialog"/> or an <see cref="SaveFileDialog"/>.
/// </summary> /// </summary>
[Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")]
public class FileDialogFilter public class FileDialogFilter
{ {
/// <summary> /// <summary>

9
src/Avalonia.Controls/TopLevel.cs

@ -11,6 +11,7 @@ using Avalonia.Logging;
using Avalonia.LogicalTree; using Avalonia.LogicalTree;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Rendering; using Avalonia.Rendering;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.Utilities; using Avalonia.Utilities;
@ -93,7 +94,8 @@ namespace Avalonia.Controls
private ILayoutManager? _layoutManager; private ILayoutManager? _layoutManager;
private Border? _transparencyFallbackBorder; private Border? _transparencyFallbackBorder;
private TargetWeakEventSubscriber<TopLevel, ResourcesChangedEventArgs>? _resourcesChangesSubscriber; private TargetWeakEventSubscriber<TopLevel, ResourcesChangedEventArgs>? _resourcesChangesSubscriber;
private IStorageProvider? _storageProvider;
/// <summary> /// <summary>
/// Initializes static members of the <see cref="TopLevel"/> class. /// Initializes static members of the <see cref="TopLevel"/> class.
/// </summary> /// </summary>
@ -319,6 +321,11 @@ namespace Avalonia.Controls
double IRenderRoot.RenderScaling => PlatformImpl?.RenderScaling ?? 1; double IRenderRoot.RenderScaling => PlatformImpl?.RenderScaling ?? 1;
IStyleHost IStyleHost.StylingParent => _globalStyles!; IStyleHost IStyleHost.StylingParent => _globalStyles!;
public IStorageProvider StorageProvider => _storageProvider
??= AvaloniaLocator.Current.GetService<IStorageProviderFactory>()?.CreateProvider(this)
?? (PlatformImpl as ITopLevelImplWithStorageProvider)?.StorageProvider
?? throw new InvalidOperationException("StorageProvider platform implementation is not available.");
IRenderTarget IRenderRoot.CreateRenderTarget() => CreateRenderTarget(); IRenderTarget IRenderRoot.CreateRenderTarget() => CreateRenderTarget();

6
src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs

@ -1,15 +1,17 @@
using System; using System;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Platform;
using Avalonia.Controls.Remote.Server; using Avalonia.Controls.Remote.Server;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Remote.Protocol; using Avalonia.Remote.Protocol;
using Avalonia.Remote.Protocol.Viewport; using Avalonia.Remote.Protocol.Viewport;
using Avalonia.Threading; using Avalonia.Threading;
namespace Avalonia.DesignerSupport.Remote namespace Avalonia.DesignerSupport.Remote
{ {
class PreviewerWindowImpl : RemoteServerTopLevelImpl, IWindowImpl class PreviewerWindowImpl : RemoteServerTopLevelImpl, IWindowImpl, ITopLevelImplWithStorageProvider
{ {
private readonly IAvaloniaRemoteTransportConnection _transport; private readonly IAvaloniaRemoteTransportConnection _transport;
@ -90,6 +92,8 @@ namespace Avalonia.DesignerSupport.Remote
public bool NeedsManagedDecorations => false; public bool NeedsManagedDecorations => false;
public IStorageProvider StorageProvider => new NoopStorageProvider();
public void Activate() public void Activate()
{ {
} }

1
src/Avalonia.DesignerSupport/Remote/PreviewerWindowingPlatform.cs

@ -55,7 +55,6 @@ namespace Avalonia.DesignerSupport.Remote
.Bind<IPlatformThreadingInterface>().ToConstant(threading) .Bind<IPlatformThreadingInterface>().ToConstant(threading)
.Bind<IRenderLoop>().ToConstant(new RenderLoop()) .Bind<IRenderLoop>().ToConstant(new RenderLoop())
.Bind<IRenderTimer>().ToConstant(new DefaultRenderTimer(60)) .Bind<IRenderTimer>().ToConstant(new DefaultRenderTimer(60))
.Bind<ISystemDialogImpl>().ToSingleton<SystemDialogsStub>()
.Bind<IWindowingPlatform>().ToConstant(instance) .Bind<IWindowingPlatform>().ToConstant(instance)
.Bind<IPlatformIconLoader>().ToSingleton<IconLoaderStub>() .Bind<IPlatformIconLoader>().ToSingleton<IconLoaderStub>()
.Bind<PlatformHotkeyConfiguration>().ToSingleton<PlatformHotkeyConfiguration>(); .Bind<PlatformHotkeyConfiguration>().ToSingleton<PlatformHotkeyConfiguration>();

32
src/Avalonia.DesignerSupport/Remote/Stubs.cs

@ -11,6 +11,8 @@ using Avalonia.Input;
using Avalonia.Input.Platform; using Avalonia.Input.Platform;
using Avalonia.Input.Raw; using Avalonia.Input.Raw;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using Avalonia.Rendering; using Avalonia.Rendering;
namespace Avalonia.DesignerSupport.Remote namespace Avalonia.DesignerSupport.Remote
@ -222,15 +224,6 @@ namespace Avalonia.DesignerSupport.Remote
public IWindowIconImpl LoadIcon(IBitmapImpl bitmap) => new IconStub(); public IWindowIconImpl LoadIcon(IBitmapImpl bitmap) => new IconStub();
} }
class SystemDialogsStub : ISystemDialogImpl
{
public Task<string[]> ShowFileDialogAsync(FileDialog dialog, Window parent) =>
Task.FromResult((string[])null);
public Task<string> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) =>
Task.FromResult((string)null);
}
class ScreenStub : IScreenImpl class ScreenStub : IScreenImpl
{ {
public int ScreenCount => 1; public int ScreenCount => 1;
@ -253,4 +246,25 @@ namespace Avalonia.DesignerSupport.Remote
return ScreenHelper.ScreenFromWindow(window, AllScreens); return ScreenHelper.ScreenFromWindow(window, AllScreens);
} }
} }
internal class NoopStorageProvider : BclStorageProvider
{
public override bool CanOpen => false;
public override Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
{
return Task.FromResult<IReadOnlyList<IStorageFile>>(Array.Empty<IStorageFile>());
}
public override bool CanSave => false;
public override Task<IStorageFile> SaveFilePickerAsync(FilePickerSaveOptions options)
{
return Task.FromResult<IStorageFile>(null);
}
public override bool CanPickFolder => false;
public override Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{
return Task.FromResult<IReadOnlyList<IStorageFolder>>(Array.Empty<IStorageFolder>());
}
}
} }

4
src/Avalonia.Dialogs/Avalonia.Dialogs.csproj

@ -14,10 +14,6 @@
<ProjectReference Include="..\Markup\Avalonia.Markup.Xaml\Avalonia.Markup.Xaml.csproj" /> <ProjectReference Include="..\Markup\Avalonia.Markup.Xaml\Avalonia.Markup.Xaml.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup Label="InternalsVisibleTo">
<InternalsVisibleTo Include="Avalonia.X11, PublicKey=$(AvaloniaPublicKey)" />
</ItemGroup>
<Import Project="..\..\build\ApiDiff.props" /> <Import Project="..\..\build\ApiDiff.props" />
<Import Project="..\..\build\DevAnalyzers.props" /> <Import Project="..\..\build\DevAnalyzers.props" />
</Project> </Project>

35
src/Avalonia.Dialogs/ManagedFileChooserFilterViewModel.cs

@ -1,48 +1,31 @@
using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using Avalonia.Controls; using System.Text.RegularExpressions;
using Avalonia.Platform.Storage;
namespace Avalonia.Dialogs namespace Avalonia.Dialogs
{ {
internal class ManagedFileChooserFilterViewModel : InternalViewModelBase internal class ManagedFileChooserFilterViewModel : InternalViewModelBase
{ {
private readonly string[] _extensions; private readonly Regex[] _patterns;
public string Name { get; } public string Name { get; }
public ManagedFileChooserFilterViewModel(FileDialogFilter filter) public ManagedFileChooserFilterViewModel(FilePickerFileType filter)
{ {
Name = filter.Name; Name = filter.Name;
if (filter.Extensions.Contains("*")) if (filter.Patterns?.Contains("*.*") == true)
{ {
return; return;
} }
_extensions = filter.Extensions?.Select(e => "." + e.ToLowerInvariant()).ToArray(); _patterns = filter.Patterns?
} .Select(e => new Regex(Regex.Escape(e).Replace(@"\*", ".*").Replace(@"\?", "."), RegexOptions.Singleline | RegexOptions.IgnoreCase))
.ToArray();
public ManagedFileChooserFilterViewModel()
{
Name = "All files";
} }
public bool Match(string filename) public bool Match(string filename)
{ {
if (_extensions == null) return _patterns == null || _patterns.Any(ext => ext.IsMatch(filename));
{
return true;
}
foreach (var ext in _extensions)
{
if (filename.EndsWith(ext, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
}
return false;
} }
public override string ToString() => Name; public override string ToString() => Name;

97
src/Avalonia.Dialogs/ManagedFileChooserViewModel.cs

@ -8,6 +8,7 @@ using System.Runtime.InteropServices;
using Avalonia.Collections; using Avalonia.Collections;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Platform; using Avalonia.Controls.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Threading; using Avalonia.Threading;
namespace Avalonia.Dialogs namespace Avalonia.Dialogs
@ -106,7 +107,7 @@ namespace Avalonia.Dialogs
QuickLinks.AddRange(quickSources.GetAllItems().Select(i => new ManagedFileChooserItemViewModel(i))); QuickLinks.AddRange(quickSources.GetAllItems().Select(i => new ManagedFileChooserItemViewModel(i)));
} }
public ManagedFileChooserViewModel(FileSystemDialog dialog, ManagedFileDialogOptions options) public ManagedFileChooserViewModel(ManagedFileDialogOptions options)
{ {
_options = options; _options = options;
_disposables = new CompositeDisposable(); _disposables = new CompositeDisposable();
@ -131,50 +132,63 @@ namespace Avalonia.Dialogs
CancelRequested += delegate { _disposables?.Dispose(); }; CancelRequested += delegate { _disposables?.Dispose(); };
RefreshQuickLinks(quickSources); RefreshQuickLinks(quickSources);
SelectedItems.CollectionChanged += OnSelectionChangedAsync;
}
Title = dialog.Title ?? ( public ManagedFileChooserViewModel(FilePickerOpenOptions filePickerOpen, ManagedFileDialogOptions options)
dialog is OpenFileDialog ? "Open file" : this(options)
: dialog is SaveFileDialog ? "Save file" {
: dialog is OpenFolderDialog ? "Select directory" Title = filePickerOpen.Title ?? "Open file";
: throw new ArgumentException(nameof(dialog)));
if (filePickerOpen.FileTypeFilter?.Count > 0)
var directory = dialog.Directory;
if (directory == null || !Directory.Exists(directory))
{ {
directory = Directory.GetCurrentDirectory(); Filters.AddRange(filePickerOpen.FileTypeFilter.Select(f => new ManagedFileChooserFilterViewModel(f)));
_selectedFilter = Filters[0];
ShowFilters = true;
} }
if (dialog is FileDialog fd) if (filePickerOpen.AllowMultiple)
{ {
if (fd.Filters?.Count > 0) SelectionMode = SelectionMode.Multiple;
{ }
Filters.AddRange(fd.Filters.Select(f => new ManagedFileChooserFilterViewModel(f)));
_selectedFilter = Filters[0];
ShowFilters = true;
}
if (dialog is OpenFileDialog ofd) Navigate(filePickerOpen.SuggestedStartLocation);
{ }
if (ofd.AllowMultiple)
{ public ManagedFileChooserViewModel(FilePickerSaveOptions filePickerSave, ManagedFileDialogOptions options)
SelectionMode = SelectionMode.Multiple; : this(options)
} {
} Title = filePickerSave.Title ?? "Save file";
if (filePickerSave.FileTypeChoices?.Count > 0)
{
Filters.AddRange(filePickerSave.FileTypeChoices.Select(f => new ManagedFileChooserFilterViewModel(f)));
_selectedFilter = Filters[0];
ShowFilters = true;
} }
_savingFile = true;
_defaultExtension = filePickerSave.DefaultExtension;
_overwritePrompt = filePickerSave.ShowOverwritePrompt ?? true;
FileName = filePickerSave.SuggestedFileName;
Navigate(filePickerSave.SuggestedStartLocation, FileName);
}
public ManagedFileChooserViewModel(FolderPickerOpenOptions folderPickerOpen, ManagedFileDialogOptions options)
: this(options)
{
Title = folderPickerOpen.Title ?? "Select directory";
_selectingDirectory = dialog is OpenFolderDialog; _selectingDirectory = true;
if (dialog is SaveFileDialog sfd) if (folderPickerOpen.AllowMultiple)
{ {
_savingFile = true; SelectionMode = SelectionMode.Multiple;
_defaultExtension = sfd.DefaultExtension;
_overwritePrompt = sfd.ShowOverwritePrompt ?? true;
FileName = sfd.InitialFileName;
} }
Navigate(directory, (dialog as FileDialog)?.InitialFileName); Navigate(folderPickerOpen.SuggestedStartLocation);
SelectedItems.CollectionChanged += OnSelectionChangedAsync;
} }
public void EnterPressed() public void EnterPressed()
@ -247,6 +261,23 @@ namespace Avalonia.Dialogs
public void Refresh() => Navigate(Location); public void Refresh() => Navigate(Location);
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();
}
Navigate(fullDirectoryPath, initialSelectionName);
}
public void Navigate(string path, string initialSelectionName = null) public void Navigate(string path, string initialSelectionName = null)
{ {
if (!Directory.Exists(path)) if (!Directory.Exists(path))

142
src/Avalonia.Dialogs/ManagedFileDialogExtensions.cs

@ -1,125 +1,26 @@
using System.IO; #nullable enable
using System;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Platform; using Avalonia.Controls.Platform;
using Avalonia.Platform.Storage;
namespace Avalonia.Dialogs namespace Avalonia.Dialogs
{ {
public static class ManagedFileDialogExtensions public static class ManagedFileDialogExtensions
{ {
internal class ManagedSystemDialogImpl<T> : ISystemDialogImpl where T : Window, new() internal class ManagedStorageProviderFactory<T> : IStorageProviderFactory where T : Window, new()
{ {
async Task<string[]> Show(SystemDialog d, Window parent, ManagedFileDialogOptions options = null) public IStorageProvider CreateProvider(TopLevel topLevel)
{ {
var model = new ManagedFileChooserViewModel((FileSystemDialog)d, if (topLevel is Window window)
options ?? new ManagedFileDialogOptions());
var dialog = new T
{
Content = new ManagedFileChooser(),
Title = d.Title,
DataContext = model
};
dialog.Closed += delegate { model.Cancel(); };
string[] result = null;
model.CompleteRequested += items =>
{
result = items;
dialog.Close();
};
model.OverwritePrompt += async (filename) =>
{ {
Window overwritePromptDialog = new Window() var options = AvaloniaLocator.Current.GetService<ManagedFileDialogOptions>();
{ return new ManagedStorageProvider<T>(window, options);
Title = "Confirm Save As", }
SizeToContent = SizeToContent.WidthAndHeight, throw new InvalidOperationException("Current platform doesn't support managed picker dialogs");
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Padding = new Thickness(10),
MinWidth = 270
};
string name = Path.GetFileName(filename);
var panel = new DockPanel()
{
HorizontalAlignment = Layout.HorizontalAlignment.Stretch
};
var label = new Label()
{
Content = $"{name} already exists.\nDo you want to replace it?"
};
panel.Children.Add(label);
DockPanel.SetDock(label, Dock.Top);
var buttonPanel = new StackPanel()
{
HorizontalAlignment = Layout.HorizontalAlignment.Right,
Orientation = Layout.Orientation.Horizontal,
Spacing = 10
};
var button = new Button()
{
Content = "Yes",
HorizontalAlignment = Layout.HorizontalAlignment.Right
};
button.Click += (sender, args) =>
{
result = new string[1] { filename };
overwritePromptDialog.Close();
dialog.Close();
};
buttonPanel.Children.Add(button);
button = new Button()
{
Content = "No",
HorizontalAlignment = Layout.HorizontalAlignment.Right
};
button.Click += (sender, args) =>
{
overwritePromptDialog.Close();
};
buttonPanel.Children.Add(button);
panel.Children.Add(buttonPanel);
DockPanel.SetDock(buttonPanel, Dock.Bottom);
overwritePromptDialog.Content = panel;
await overwritePromptDialog.ShowDialog(dialog);
};
model.CancelRequested += dialog.Close;
await dialog.ShowDialog<object>(parent);
return result;
}
public async Task<string[]> ShowFileDialogAsync(FileDialog dialog, Window parent)
{
return await Show(dialog, parent);
}
public async Task<string> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent)
{
return (await Show(dialog, parent))?.FirstOrDefault();
}
public async Task<string[]> ShowFileDialogAsync(FileDialog dialog, Window parent, ManagedFileDialogOptions options)
{
return await Show(dialog, parent, options);
} }
} }
@ -127,7 +28,7 @@ namespace Avalonia.Dialogs
where TAppBuilder : AppBuilderBase<TAppBuilder>, new() where TAppBuilder : AppBuilderBase<TAppBuilder>, new()
{ {
builder.AfterSetup(_ => builder.AfterSetup(_ =>
AvaloniaLocator.CurrentMutable.Bind<ISystemDialogImpl>().ToSingleton<ManagedSystemDialogImpl<Window>>()); AvaloniaLocator.CurrentMutable.Bind<IStorageProviderFactory>().ToSingleton<ManagedStorageProviderFactory<Window>>());
return builder; return builder;
} }
@ -135,17 +36,26 @@ namespace Avalonia.Dialogs
where TAppBuilder : AppBuilderBase<TAppBuilder>, new() where TWindow : Window, new() where TAppBuilder : AppBuilderBase<TAppBuilder>, new() where TWindow : Window, new()
{ {
builder.AfterSetup(_ => builder.AfterSetup(_ =>
AvaloniaLocator.CurrentMutable.Bind<ISystemDialogImpl>().ToSingleton<ManagedSystemDialogImpl<TWindow>>()); AvaloniaLocator.CurrentMutable.Bind<IStorageProviderFactory>().ToSingleton<ManagedStorageProviderFactory<TWindow>>());
return builder; return builder;
} }
[Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")]
public static Task<string[]> ShowManagedAsync(this OpenFileDialog dialog, Window parent, public static Task<string[]> ShowManagedAsync(this OpenFileDialog dialog, Window parent,
ManagedFileDialogOptions options = null) => ShowManagedAsync<Window>(dialog, parent, options); ManagedFileDialogOptions? options = null) => ShowManagedAsync<Window>(dialog, parent, options);
public static Task<string[]> ShowManagedAsync<TWindow>(this OpenFileDialog dialog, Window parent, [Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")]
ManagedFileDialogOptions options = null) where TWindow : Window, new() public static async Task<string[]> ShowManagedAsync<TWindow>(this OpenFileDialog dialog, Window parent,
ManagedFileDialogOptions? options = null) where TWindow : Window, new()
{ {
return new ManagedSystemDialogImpl<TWindow>().ShowFileDialogAsync(dialog, parent, options); var impl = new ManagedStorageProvider<TWindow>(parent, options);
var files = await impl.OpenFilePickerAsync(dialog.ToFilePickerOpenOptions());
return files
.Select(file => file.TryGetUri(out var fullPath)
? fullPath.LocalPath
: file.Name)
.ToArray();
} }
} }
} }

147
src/Avalonia.Dialogs/ManagedStorageProvider.cs

@ -0,0 +1,147 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
namespace Avalonia.Dialogs;
public class ManagedStorageProvider<T> : BclStorageProvider where T : Window, new()
{
private readonly Window _parent;
private readonly ManagedFileDialogOptions _managedOptions;
public ManagedStorageProvider(Window parent, ManagedFileDialogOptions? managedOptions)
{
_parent = parent;
_managedOptions = managedOptions ?? new ManagedFileDialogOptions();
}
public override bool CanSave => true;
public override bool CanOpen => true;
public override bool CanPickFolder => true;
public override async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
{
var model = new ManagedFileChooserViewModel(options, _managedOptions);
var results = await Show(model, _parent);
return results.Select(f => new BclStorageFile(new FileInfo(f))).ToArray();
}
public override async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
{
var model = new ManagedFileChooserViewModel(options, _managedOptions);
var results = await Show(model, _parent);
return results.FirstOrDefault() is { } result
? new BclStorageFile(new FileInfo(result))
: null;
}
public override async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{
var model = new ManagedFileChooserViewModel(options, _managedOptions);
var results = await Show(model, _parent);
return results.Select(f => new BclStorageFolder(new DirectoryInfo(f))).ToArray();
}
private async Task<string[]> Show(ManagedFileChooserViewModel model, Window parent)
{
var dialog = new T
{
Content = new ManagedFileChooser(),
Title = model.Title,
DataContext = model
};
dialog.Closed += delegate { model.Cancel(); };
string[]? result = null;
model.CompleteRequested += items =>
{
result = items;
dialog.Close();
};
model.OverwritePrompt += async (filename) =>
{
var overwritePromptDialog = new Window()
{
Title = "Confirm Save As",
SizeToContent = SizeToContent.WidthAndHeight,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Padding = new Thickness(10),
MinWidth = 270
};
string name = Path.GetFileName(filename);
var panel = new DockPanel()
{
HorizontalAlignment = Layout.HorizontalAlignment.Stretch
};
var label = new Label()
{
Content = $"{name} already exists.\nDo you want to replace it?"
};
panel.Children.Add(label);
DockPanel.SetDock(label, Dock.Top);
var buttonPanel = new StackPanel()
{
HorizontalAlignment = Layout.HorizontalAlignment.Right,
Orientation = Layout.Orientation.Horizontal,
Spacing = 10
};
var button = new Button()
{
Content = "Yes",
HorizontalAlignment = Layout.HorizontalAlignment.Right
};
button.Click += (sender, args) =>
{
result = new string[1] { filename };
overwritePromptDialog.Close();
dialog.Close();
};
buttonPanel.Children.Add(button);
button = new Button()
{
Content = "No",
HorizontalAlignment = Layout.HorizontalAlignment.Right
};
button.Click += (sender, args) =>
{
overwritePromptDialog.Close();
};
buttonPanel.Children.Add(button);
panel.Children.Add(buttonPanel);
DockPanel.SetDock(buttonPanel, Dock.Bottom);
overwritePromptDialog.Content = panel;
await overwritePromptDialog.ShowDialog(dialog);
};
model.CancelRequested += dialog.Close;
await dialog.ShowDialog<object>(parent);
return result ?? Array.Empty<string>();
}
}

159
src/Avalonia.FreeDesktop/DBusSystemDialog.cs

@ -1,102 +1,171 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.Platform;
using Avalonia.Logging; using Avalonia.Logging;
using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using Tmds.DBus; using Tmds.DBus;
namespace Avalonia.FreeDesktop namespace Avalonia.FreeDesktop
{ {
internal class DBusSystemDialog : ISystemDialogImpl internal class DBusSystemDialog : BclStorageProvider
{ {
private readonly IFileChooser _fileChooser; private static readonly Lazy<IFileChooser?> s_fileChooser = new(() =>
internal static DBusSystemDialog? TryCreate()
{ {
var fileChooser = DBusHelper.Connection?.CreateProxy<IFileChooser>("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop"); var fileChooser = DBusHelper.Connection?.CreateProxy<IFileChooser>("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop");
if (fileChooser is null) if (fileChooser is null)
return null; return null;
try try
{ {
fileChooser.GetVersionAsync().GetAwaiter().GetResult(); _ = fileChooser.GetVersionAsync();
return new DBusSystemDialog(fileChooser); return fileChooser;
} }
catch (Exception e) catch (Exception e)
{ {
Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform)?.Log(null, $"Unable to connect to org.freedesktop.portal.Desktop: {e.Message}"); Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform)?.Log(null, $"Unable to connect to org.freedesktop.portal.Desktop: {e.Message}");
return null; return null;
} }
});
internal static DBusSystemDialog? TryCreate(IPlatformHandle handle)
{
return handle.HandleDescriptor == "XID" && s_fileChooser.Value is { } fileChooser
? new DBusSystemDialog(fileChooser, handle) : null;
} }
private DBusSystemDialog(IFileChooser fileChooser) private readonly IFileChooser _fileChooser;
private readonly IPlatformHandle _handle;
private DBusSystemDialog(IFileChooser fileChooser, IPlatformHandle handle)
{ {
_fileChooser = fileChooser; _fileChooser = fileChooser;
_handle = handle;
} }
public async Task<string[]?> ShowFileDialogAsync(FileDialog dialog, Window parent) public override bool CanOpen => true;
public override bool CanSave => true;
public override bool CanPickFolder => true;
public override async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
{ {
var parentWindow = $"x11:{parent.PlatformImpl!.Handle.Handle.ToString("X")}"; var parentWindow = $"x11:{_handle.Handle:X}";
ObjectPath objectPath; ObjectPath objectPath;
var options = new Dictionary<string, object>(); var chooserOptions = new Dictionary<string, object>();
if (dialog.Filters is not null) var filters = ParseFilters(options.FileTypeFilter);
options.Add("filters", ParseFilters(dialog)); if (filters.Any())
{
chooserOptions.Add("filters", filters);
}
chooserOptions.Add("multiple", options.AllowMultiple);
objectPath = await _fileChooser.OpenFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions);
var request = DBusHelper.Connection!.CreateProxy<IRequest>("org.freedesktop.portal.Request", objectPath);
var tsc = new TaskCompletionSource<string[]?>();
using var disposable = await request.WatchResponseAsync(x => tsc.SetResult(x.results["uris"] as string[]), tsc.SetException);
var uris = await tsc.Task ?? Array.Empty<string>();
return uris.Select(path => new BclStorageFile(new FileInfo(new Uri(path).AbsolutePath))).ToList();
}
switch (dialog) public override async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
{
var parentWindow = $"x11:{_handle.Handle:X}";
ObjectPath objectPath;
var chooserOptions = new Dictionary<string, object>();
var filters = ParseFilters(options.FileTypeChoices);
if (filters.Any())
{ {
case OpenFileDialog openFileDialog: chooserOptions.Add("filters", filters);
options.Add("multiple", openFileDialog.AllowMultiple);
objectPath = await _fileChooser.OpenFileAsync(parentWindow, openFileDialog.Title ?? string.Empty, options);
break;
case SaveFileDialog saveFileDialog:
if (saveFileDialog.InitialFileName is not null)
options.Add("current_name", saveFileDialog.InitialFileName);
if (saveFileDialog.Directory is not null)
options.Add("current_folder", Encoding.UTF8.GetBytes(saveFileDialog.Directory));
objectPath = await _fileChooser.SaveFileAsync(parentWindow, saveFileDialog.Title ?? string.Empty, options);
break;
} }
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()));
objectPath = await _fileChooser.SaveFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions);
var request = DBusHelper.Connection!.CreateProxy<IRequest>("org.freedesktop.portal.Request", objectPath); var request = DBusHelper.Connection!.CreateProxy<IRequest>("org.freedesktop.portal.Request", objectPath);
var tsc = new TaskCompletionSource<string[]?>(); var tsc = new TaskCompletionSource<string[]?>();
using var disposable = await request.WatchResponseAsync(x => tsc.SetResult(x.results["uris"] as string[]), tsc.SetException); using var disposable = await request.WatchResponseAsync(x => tsc.SetResult(x.results["uris"] as string[]), tsc.SetException);
var uris = await tsc.Task; var uris = await tsc.Task;
if (uris is null) var path = uris?.FirstOrDefault() is { } filePath ? new Uri(filePath).AbsolutePath : null;
if (path is null)
{
return null; return null;
for (var i = 0; i < uris.Length; i++) }
uris[i] = new Uri(uris[i]).AbsolutePath; else
return uris; {
// WSL2 freedesktop automatically adds extension from selected file type, but we can't pass "default ext". So apply it manually.
path = StorageProviderHelpers.NameWithExtension(path, options.DefaultExtension, null);
return new BclStorageFile(new FileInfo(path));
}
} }
public async Task<string?> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) public override async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{ {
var parentWindow = $"x11:{parent.PlatformImpl!.Handle.Handle.ToString("X")}"; var parentWindow = $"x11:{_handle.Handle:X}";
var options = new Dictionary<string, object> var chooserOptions = new Dictionary<string, object>
{ {
{ "directory", true } { "directory", true },
{ "multiple", options.AllowMultiple }
}; };
var objectPath = await _fileChooser.OpenFileAsync(parentWindow, dialog.Title ?? string.Empty, options); var objectPath = await _fileChooser.OpenFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions);
var request = DBusHelper.Connection!.CreateProxy<IRequest>("org.freedesktop.portal.Request", objectPath); var request = DBusHelper.Connection!.CreateProxy<IRequest>("org.freedesktop.portal.Request", objectPath);
var tsc = new TaskCompletionSource<string[]?>(); var tsc = new TaskCompletionSource<string[]?>();
using var disposable = await request.WatchResponseAsync(x => tsc.SetResult(x.results["uris"] as string[]), tsc.SetException); using var disposable = await request.WatchResponseAsync(x => tsc.SetResult(x.results["uris"] as string[]), tsc.SetException);
var uris = await tsc.Task; var uris = await tsc.Task ?? Array.Empty<string>();
if (uris is null)
return null; return uris
return uris.Length != 1 ? string.Empty : new Uri(uris[0]).AbsolutePath; .Select(path => new Uri(path).AbsolutePath)
// WSL2 freedesktop allows to select files as well in directory picker, filter it out.
.Where(Directory.Exists)
.Select(path => new BclStorageFolder(new DirectoryInfo(path))).ToList();
} }
private static (string name, (uint style, string extension)[])[] ParseFilters(FileDialog dialog) private static (string name, (uint style, string extension)[])[] ParseFilters(IReadOnlyList<FilePickerFileType>? fileTypes)
{ {
var filters = new (string name, (uint style, string extension)[])[dialog.Filters!.Count]; // Example: [('Images', [(0, '*.ico'), (1, 'image/png')]), ('Text', [(0, '*.txt')])]
for (var i = 0; i < filters.Length; i++)
if (fileTypes is null)
{ {
var extensions = dialog.Filters[i].Extensions.Select(static x => (0u, x)).ToArray(); return Array.Empty<(string name, (uint style, string extension)[])>();
filters[i] = (dialog.Filters[i].Name ?? string.Empty, extensions); }
var filters = new List<(string name, (uint style, string extension)[])>();
foreach (var fileType in fileTypes)
{
const uint globStyle = 0u;
const uint mimeStyle = 1u;
var extensions = Enumerable.Empty<(uint, string)>();
if (fileType.Patterns is { } patterns)
{
extensions = extensions.Concat(patterns.Select(static x => (globStyle, x)));
}
else if (fileType.MimeTypes is { } mimeTypes)
{
extensions = extensions.Concat(mimeTypes.Select(static x => (mimeStyle, x)));
}
if (extensions.Any())
{
filters.Add((fileType.Name, extensions.ToArray()));
}
} }
return filters; return filters.ToArray();
} }
} }
} }

1
src/Avalonia.Headless/AvaloniaHeadlessPlatform.cs

@ -62,7 +62,6 @@ namespace Avalonia.Headless
.Bind<IClipboard>().ToSingleton<HeadlessClipboardStub>() .Bind<IClipboard>().ToSingleton<HeadlessClipboardStub>()
.Bind<ICursorFactory>().ToSingleton<HeadlessCursorFactoryStub>() .Bind<ICursorFactory>().ToSingleton<HeadlessCursorFactoryStub>()
.Bind<IPlatformSettings>().ToConstant(new HeadlessPlatformSettingsStub()) .Bind<IPlatformSettings>().ToConstant(new HeadlessPlatformSettingsStub())
.Bind<ISystemDialogImpl>().ToSingleton<HeadlessSystemDialogsStub>()
.Bind<IPlatformIconLoader>().ToSingleton<HeadlessIconLoaderStub>() .Bind<IPlatformIconLoader>().ToSingleton<HeadlessIconLoaderStub>()
.Bind<IKeyboardDevice>().ToConstant(new KeyboardDevice()) .Bind<IKeyboardDevice>().ToConstant(new KeyboardDevice())
.Bind<IRenderLoop>().ToConstant(new RenderLoop()) .Bind<IRenderLoop>().ToConstant(new RenderLoop())

36
src/Avalonia.Headless/HeadlessPlatformStubs.cs

@ -12,6 +12,8 @@ using Avalonia.Media;
using Avalonia.Media.Fonts; using Avalonia.Media.Fonts;
using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using Avalonia.Utilities; using Avalonia.Utilities;
namespace Avalonia.Headless namespace Avalonia.Headless
@ -73,19 +75,6 @@ namespace Avalonia.Headless
public TimeSpan TouchDoubleClickTime => DoubleClickTime; public TimeSpan TouchDoubleClickTime => DoubleClickTime;
} }
class HeadlessSystemDialogsStub : ISystemDialogImpl
{
public Task<string[]> ShowFileDialogAsync(FileDialog dialog, Window parent)
{
return Task.Run(() => (string[])null);
}
public Task<string> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent)
{
return Task.Run(() => (string)null);
}
}
class HeadlessGlyphTypefaceImpl : IGlyphTypefaceImpl class HeadlessGlyphTypefaceImpl : IGlyphTypefaceImpl
{ {
public short DesignEmHeight => 10; public short DesignEmHeight => 10;
@ -219,4 +208,25 @@ namespace Avalonia.Headless
return ScreenHelper.ScreenFromWindow(window, AllScreens); return ScreenHelper.ScreenFromWindow(window, AllScreens);
} }
} }
internal class NoopStorageProvider : BclStorageProvider
{
public override bool CanOpen => false;
public override Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
{
return Task.FromResult<IReadOnlyList<IStorageFile>>(Array.Empty<IStorageFile>());
}
public override bool CanSave => false;
public override Task<IStorageFile> SaveFilePickerAsync(FilePickerSaveOptions options)
{
return Task.FromResult<IStorageFile>(null);
}
public override bool CanPickFolder => false;
public override Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{
return Task.FromResult<IReadOnlyList<IStorageFolder>>(Array.Empty<IStorageFolder>());
}
}
} }

6
src/Avalonia.Headless/HeadlessWindowImpl.cs

@ -3,19 +3,21 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using Avalonia.Automation.Peers; using Avalonia.Automation.Peers;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Platform;
using Avalonia.Controls.Platform.Surfaces; using Avalonia.Controls.Platform.Surfaces;
using Avalonia.Controls.Primitives.PopupPositioning; using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Input.Raw; using Avalonia.Input.Raw;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Rendering; using Avalonia.Rendering;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.Utilities; using Avalonia.Utilities;
namespace Avalonia.Headless namespace Avalonia.Headless
{ {
class HeadlessWindowImpl : IWindowImpl, IPopupImpl, IFramebufferPlatformSurface, IHeadlessWindow class HeadlessWindowImpl : IWindowImpl, IPopupImpl, IFramebufferPlatformSurface, IHeadlessWindow, ITopLevelImplWithStorageProvider
{ {
private IKeyboardDevice _keyboard; private IKeyboardDevice _keyboard;
private Stopwatch _st = Stopwatch.StartNew(); private Stopwatch _st = Stopwatch.StartNew();
@ -245,6 +247,8 @@ namespace Avalonia.Headless
public AcrylicPlatformCompensationLevels AcrylicCompensationLevels => new AcrylicPlatformCompensationLevels(1, 1, 1); public AcrylicPlatformCompensationLevels AcrylicCompensationLevels => new AcrylicPlatformCompensationLevels(1, 1, 1);
public IStorageProvider StorageProvider => new NoopStorageProvider();
void IHeadlessWindow.KeyPress(Key key, RawInputModifiers modifiers) void IHeadlessWindow.KeyPress(Key key, RawInputModifiers modifiers)
{ {
Input?.Invoke(new RawKeyEventArgs(_keyboard, Timestamp, InputRoot, RawKeyEventType.KeyDown, key, modifiers)); Input?.Invoke(new RawKeyEventArgs(_keyboard, Timestamp, InputRoot, RawKeyEventType.KeyDown, key, modifiers));

1
src/Avalonia.Native/AvaloniaNativePlatform.cs

@ -112,7 +112,6 @@ namespace Avalonia.Native
.Bind<IClipboard>().ToConstant(new ClipboardImpl(_factory.CreateClipboard())) .Bind<IClipboard>().ToConstant(new ClipboardImpl(_factory.CreateClipboard()))
.Bind<IRenderLoop>().ToConstant(new RenderLoop()) .Bind<IRenderLoop>().ToConstant(new RenderLoop())
.Bind<IRenderTimer>().ToConstant(new DefaultRenderTimer(60)) .Bind<IRenderTimer>().ToConstant(new DefaultRenderTimer(60))
.Bind<ISystemDialogImpl>().ToConstant(new SystemDialogs(_factory.CreateSystemDialogs()))
.Bind<PlatformHotkeyConfiguration>().ToConstant(new PlatformHotkeyConfiguration(KeyModifiers.Meta, wholeWordTextActionModifiers: KeyModifiers.Alt)) .Bind<PlatformHotkeyConfiguration>().ToConstant(new PlatformHotkeyConfiguration(KeyModifiers.Meta, wholeWordTextActionModifiers: KeyModifiers.Alt))
.Bind<IMountedVolumeInfoProvider>().ToConstant(new MacOSMountedVolumeInfoProvider()) .Bind<IMountedVolumeInfoProvider>().ToConstant(new MacOSMountedVolumeInfoProvider())
.Bind<IPlatformDragSource>().ToConstant(new AvaloniaNativeDragSource(_factory)) .Bind<IPlatformDragSource>().ToConstant(new AvaloniaNativeDragSource(_factory))

118
src/Avalonia.Native/SystemDialogs.cs

@ -1,70 +1,114 @@
using System; #nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.Platform;
using Avalonia.Native.Interop; using Avalonia.Native.Interop;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
namespace Avalonia.Native namespace Avalonia.Native
{ {
internal class SystemDialogs : ISystemDialogImpl internal class SystemDialogs : BclStorageProvider
{ {
IAvnSystemDialogs _native; private readonly WindowBaseImpl _window;
private readonly IAvnSystemDialogs _native;
public SystemDialogs(IAvnSystemDialogs native) public SystemDialogs(WindowBaseImpl window, IAvnSystemDialogs native)
{ {
_window = window;
_native = native; _native = native;
} }
public Task<string[]> ShowFileDialogAsync(FileDialog dialog, Window parent) public override bool CanOpen => true;
public override bool CanSave => true;
public override bool CanPickFolder => true;
public override async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
{ {
var events = new SystemDialogEvents(); using var events = new SystemDialogEvents();
var nativeParent = GetNativeWindow(parent); var suggestedDirectory = options.SuggestedStartLocation?.TryGetUri(out var suggestedDirectoryTmp) == true
? suggestedDirectoryTmp.LocalPath : string.Empty;
if (dialog is OpenFileDialog ofd) _native.OpenFileDialog((IAvnWindow)_window.Native,
{ events,
_native.OpenFileDialog(nativeParent, options.AllowMultiple.AsComBool(),
events, ofd.AllowMultiple.AsComBool(), options.Title ?? string.Empty,
ofd.Title ?? "", suggestedDirectory,
ofd.Directory ?? "", string.Empty,
ofd.InitialFileName ?? "", PrepareFilterParameter(options.FileTypeFilter));
string.Join(";", dialog.Filters?.SelectMany(f => f.Extensions) ?? Array.Empty<string>()));
} var result = await events.Task.ConfigureAwait(false);
else
{ return result?.Select(f => new BclStorageFile(new FileInfo(f))).ToArray()
_native.SaveFileDialog(nativeParent, ?? Array.Empty<IStorageFile>();
events, }
dialog.Title ?? "",
dialog.Directory ?? "",
dialog.InitialFileName ?? "",
string.Join(";", dialog.Filters?.SelectMany(f => f.Extensions) ?? Array.Empty<string>()));
}
return events.Task.ContinueWith(t => { events.Dispose(); return t.Result; }); public override async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
{
using var events = new SystemDialogEvents();
var suggestedDirectory = options.SuggestedStartLocation?.TryGetUri(out var suggestedDirectoryTmp) == true
? suggestedDirectoryTmp.LocalPath : string.Empty;
_native.SaveFileDialog((IAvnWindow)_window.Native,
events,
options.Title ?? string.Empty,
suggestedDirectory,
options.SuggestedFileName ?? string.Empty,
PrepareFilterParameter(options.FileTypeChoices));
var result = await events.Task.ConfigureAwait(false);
return result.FirstOrDefault() is string file
? new BclStorageFile(new FileInfo(file))
: null;
} }
public Task<string> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) public override async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{ {
var events = new SystemDialogEvents(); using var events = new SystemDialogEvents();
var nativeParent = GetNativeWindow(parent); var suggestedDirectory = options.SuggestedStartLocation?.TryGetUri(out var suggestedDirectoryTmp) == true
? suggestedDirectoryTmp.LocalPath : string.Empty;
_native.SelectFolderDialog(nativeParent, events, dialog.Title ?? "", dialog.Directory ?? ""); _native.SelectFolderDialog((IAvnWindow)_window.Native, events, options.AllowMultiple.AsComBool(), options.Title ?? "", suggestedDirectory);
return events.Task.ContinueWith(t => { events.Dispose(); return t.Result.FirstOrDefault(); }); var result = await events.Task.ConfigureAwait(false);
return result?.Select(f => new BclStorageFolder(new DirectoryInfo(f))).ToArray()
?? Array.Empty<IStorageFolder>();
} }
private IAvnWindow GetNativeWindow(Window window) private static string PrepareFilterParameter(IReadOnlyList<FilePickerFileType>? fileTypes)
{ {
return (window?.PlatformImpl as WindowImpl)?.Native; return string.Join(";",
fileTypes?.SelectMany(f =>
{
// On the native side we will try to parse identifiers or mimetypes.
if (f.AppleUniformTypeIdentifiers?.Any() == true)
{
return f.AppleUniformTypeIdentifiers;
}
else if (f.MimeTypes?.Any() == true)
{
// MacOS doesn't accept "all" type, so it's pointless to pass it.
return f.MimeTypes.Where(t => t != "*/*");
}
return Array.Empty<string>();
}) ??
Array.Empty<string>());
} }
} }
internal unsafe class SystemDialogEvents : NativeCallbackBase, IAvnSystemDialogEvents internal unsafe class SystemDialogEvents : NativeCallbackBase, IAvnSystemDialogEvents
{ {
private TaskCompletionSource<string[]> _tcs; private readonly TaskCompletionSource<string[]> _tcs;
public SystemDialogEvents() public SystemDialogEvents()
{ {
@ -83,7 +127,7 @@ namespace Avalonia.Native
for (int i = 0; i < numResults; i++) for (int i = 0; i < numResults; i++)
{ {
results[i] = Marshal.PtrToStringAnsi(*ptr); results[i] = Marshal.PtrToStringAnsi(*ptr) ?? string.Empty;
ptr++; ptr++;
} }

7
src/Avalonia.Native/WindowImplBase.cs

@ -11,6 +11,7 @@ using Avalonia.Input.Raw;
using Avalonia.Native.Interop; using Avalonia.Native.Interop;
using Avalonia.OpenGL; using Avalonia.OpenGL;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Rendering; using Avalonia.Rendering;
using Avalonia.Threading; using Avalonia.Threading;
@ -45,7 +46,7 @@ namespace Avalonia.Native
} }
internal abstract class WindowBaseImpl : IWindowBaseImpl, internal abstract class WindowBaseImpl : IWindowBaseImpl,
IFramebufferPlatformSurface, ITopLevelImplWithNativeControlHost IFramebufferPlatformSurface, ITopLevelImplWithNativeControlHost, ITopLevelImplWithStorageProvider
{ {
protected readonly IAvaloniaNativeFactory _factory; protected readonly IAvaloniaNativeFactory _factory;
protected IInputRoot _inputRoot; protected IInputRoot _inputRoot;
@ -73,6 +74,7 @@ namespace Avalonia.Native
_keyboard = AvaloniaLocator.Current.GetService<IKeyboardDevice>(); _keyboard = AvaloniaLocator.Current.GetService<IKeyboardDevice>();
_mouse = new MouseDevice(); _mouse = new MouseDevice();
_cursorFactory = AvaloniaLocator.Current.GetService<ICursorFactory>(); _cursorFactory = AvaloniaLocator.Current.GetService<ICursorFactory>();
StorageProvider = new SystemDialogs(this, _factory.CreateSystemDialogs());
} }
protected void Init(IAvnWindowBase window, IAvnScreens screens, IGlContext glContext) protected void Init(IAvnWindowBase window, IAvnScreens screens, IGlContext glContext)
@ -84,6 +86,7 @@ namespace Avalonia.Native
if (_gpu) if (_gpu)
_glSurface = new GlPlatformSurface(window, _glContext); _glSurface = new GlPlatformSurface(window, _glContext);
Screen = new ScreenImpl(screens); Screen = new ScreenImpl(screens);
_savedLogicalSize = ClientSize; _savedLogicalSize = ClientSize;
_savedScaling = RenderScaling; _savedScaling = RenderScaling;
_nativeControlHost = new NativeControlHostImpl(_native.CreateNativeControlHost()); _nativeControlHost = new NativeControlHostImpl(_native.CreateNativeControlHost());
@ -514,5 +517,7 @@ namespace Avalonia.Native
public AcrylicPlatformCompensationLevels AcrylicCompensationLevels { get; } = new AcrylicPlatformCompensationLevels(1, 0, 0); public AcrylicPlatformCompensationLevels AcrylicCompensationLevels { get; } = new AcrylicPlatformCompensationLevels(1, 0, 0);
public IPlatformHandle Handle { get; private set; } public IPlatformHandle Handle { get; private set; }
public IStorageProvider StorageProvider { get; }
} }
} }

7
src/Avalonia.Native/avn.idl

@ -641,9 +641,10 @@ interface IAvnSystemDialogEvents : IUnknown
interface IAvnSystemDialogs : IUnknown interface IAvnSystemDialogs : IUnknown
{ {
void SelectFolderDialog(IAvnWindow* parentWindowHandle, void SelectFolderDialog(IAvnWindow* parentWindowHandle,
IAvnSystemDialogEvents* events, IAvnSystemDialogEvents* events,
[const] char* title, bool allowMultiple,
[const] char* initialPath); [const] char* title,
[const] char* initialPath);
void OpenFileDialog(IAvnWindow* parentWindowHandle, void OpenFileDialog(IAvnWindow* parentWindowHandle,
IAvnSystemDialogEvents* events, IAvnSystemDialogEvents* events,

3
src/Avalonia.X11/NativeDialogs/Gtk.cs

@ -208,6 +208,9 @@ namespace Avalonia.X11.NativeDialogs
[DllImport(GtkName)] [DllImport(GtkName)]
public static extern IntPtr gtk_file_filter_add_pattern(IntPtr filter, Utf8Buffer pattern); public static extern IntPtr gtk_file_filter_add_pattern(IntPtr filter, Utf8Buffer pattern);
[DllImport(GtkName)]
public static extern IntPtr gtk_file_filter_add_mime_type (IntPtr filter, Utf8Buffer mimeType);
[DllImport(GtkName)] [DllImport(GtkName)]
public static extern IntPtr gtk_file_chooser_add_filter(IntPtr chooser, IntPtr filter); public static extern IntPtr gtk_file_chooser_add_filter(IntPtr chooser, IntPtr filter);

225
src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs

@ -1,57 +1,147 @@
#nullable enable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.Platform; using Avalonia.Controls.Platform;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Platform.Interop; using Avalonia.Platform.Interop;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using static Avalonia.X11.NativeDialogs.Glib; using static Avalonia.X11.NativeDialogs.Glib;
using static Avalonia.X11.NativeDialogs.Gtk; using static Avalonia.X11.NativeDialogs.Gtk;
// ReSharper disable AccessToModifiedClosure
namespace Avalonia.X11.NativeDialogs namespace Avalonia.X11.NativeDialogs
{ {
class GtkSystemDialog : ISystemDialogImpl internal class GtkSystemDialog : BclStorageProvider
{ {
private Task<bool> _initialized; private Task<bool>? _initialized;
private readonly X11Window _window;
public GtkSystemDialog(X11Window window)
{
_window = window;
}
public override bool CanOpen => true;
public override bool CanSave => true;
public override bool CanPickFolder => true;
public override async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
{
await EnsureInitialized();
return await await RunOnGlibThread(async () =>
{
var res = await ShowDialog(options.Title, _window, GtkFileChooserAction.Open,
options.AllowMultiple, options.SuggestedStartLocation, null, options.FileTypeFilter, null, false)
.ConfigureAwait(false);
return res?.Select(f => new BclStorageFile(new FileInfo(f))).ToArray() ?? Array.Empty<IStorageFile>();
});
}
public override async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{
await EnsureInitialized();
return await await RunOnGlibThread(async () =>
{
var res = await ShowDialog(options.Title, _window, GtkFileChooserAction.SelectFolder,
options.AllowMultiple, options.SuggestedStartLocation, null,
null, null, false).ConfigureAwait(false);
return res?.Select(f => new BclStorageFolder(new DirectoryInfo(f))).ToArray() ?? Array.Empty<IStorageFolder>();
});
}
public override async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
{
await EnsureInitialized();
return await await RunOnGlibThread(async () =>
{
var res = await ShowDialog(options.Title, _window, GtkFileChooserAction.Save,
false, options.SuggestedStartLocation, options.SuggestedFileName, options.FileTypeChoices, options.DefaultExtension, options.ShowOverwritePrompt ?? false)
.ConfigureAwait(false);
return res?.FirstOrDefault() is { } file
? new BclStorageFile(new FileInfo(file))
: null;
});
}
private unsafe Task<string[]> ShowDialog(string title, IWindowImpl parent, GtkFileChooserAction action, private unsafe Task<string[]?> ShowDialog(string? title, IWindowImpl parent, GtkFileChooserAction action,
bool multiSelect, string initialDirectory, string initialFileName, IEnumerable<FileDialogFilter> filters, string defaultExtension, bool overwritePrompt) bool multiSelect, IStorageFolder? initialFolder, string? initialFileName,
IEnumerable<FilePickerFileType>? filters, string? defaultExtension, bool overwritePrompt)
{ {
IntPtr dlg; IntPtr dlg;
using (var name = new Utf8Buffer(title)) using (var name = new Utf8Buffer(title))
{
dlg = gtk_file_chooser_dialog_new(name, IntPtr.Zero, action, IntPtr.Zero); dlg = gtk_file_chooser_dialog_new(name, IntPtr.Zero, action, IntPtr.Zero);
}
UpdateParent(dlg, parent); UpdateParent(dlg, parent);
if (multiSelect) if (multiSelect)
{
gtk_file_chooser_set_select_multiple(dlg, true); gtk_file_chooser_set_select_multiple(dlg, true);
}
gtk_window_set_modal(dlg, true); gtk_window_set_modal(dlg, true);
var tcs = new TaskCompletionSource<string[]>(); var tcs = new TaskCompletionSource<string[]?>();
List<IDisposable> disposables = null; List<IDisposable>? disposables = null;
void Dispose() void Dispose()
{ {
// ReSharper disable once PossibleNullReferenceException foreach (var d in disposables!)
foreach (var d in disposables) d.Dispose(); {
d.Dispose();
}
disposables.Clear(); disposables.Clear();
} }
var filtersDic = new Dictionary<IntPtr, FileDialogFilter>(); var filtersDic = new Dictionary<IntPtr, FilePickerFileType>();
if(filters != null) if (filters != null)
{
foreach (var f in filters) foreach (var f in filters)
{ {
var filter = gtk_file_filter_new(); if (f.Patterns?.Any() == true || f.MimeTypes?.Any() == true)
filtersDic[filter] = f; {
using (var b = new Utf8Buffer(f.Name)) var filter = gtk_file_filter_new();
gtk_file_filter_set_name(filter, b); filtersDic[filter] = f;
using (var b = new Utf8Buffer(f.Name))
{
gtk_file_filter_set_name(filter, b);
}
foreach (var e in f.Extensions) if (f.Patterns is not null)
using (var b = new Utf8Buffer("*." + e)) {
gtk_file_filter_add_pattern(filter, b); foreach (var e in f.Patterns)
{
using (var b = new Utf8Buffer(e))
{
gtk_file_filter_add_pattern(filter, b);
}
}
}
gtk_file_chooser_add_filter(dlg, filter); if (f.MimeTypes is not null)
{
foreach (var e in f.MimeTypes)
{
using (var b = new Utf8Buffer(e))
{
gtk_file_filter_add_mime_type(filter, b);
}
}
}
gtk_file_chooser_add_filter(dlg, filter);
}
} }
}
disposables = new List<IDisposable> disposables = new List<IDisposable>
{ {
@ -63,7 +153,7 @@ namespace Avalonia.X11.NativeDialogs
}), }),
ConnectSignal<signal_dialog_response>(dlg, "response", (_, resp, __) => ConnectSignal<signal_dialog_response>(dlg, "response", (_, resp, __) =>
{ {
string[] result = null; string[]? result = null;
if (resp == GtkResponseType.Accept) if (resp == GtkResponseType.Accept)
{ {
var resultList = new List<string>(); var resultList = new List<string>();
@ -71,20 +161,18 @@ namespace Avalonia.X11.NativeDialogs
var cgs = gs; var cgs = gs;
while (cgs != null) while (cgs != null)
{ {
if (cgs->Data != IntPtr.Zero) if (cgs->Data != IntPtr.Zero
resultList.Add(Utf8Buffer.StringFromPtr(cgs->Data)); && Utf8Buffer.StringFromPtr(cgs->Data) is string str) { resultList.Add(str); } cgs = cgs->Next;
cgs = cgs->Next;
} }
g_slist_free(gs); g_slist_free(gs);
result = resultList.ToArray(); result = resultList.ToArray();
// GTK doesn't auto-append the extension, so we need to do that manually // GTK doesn't auto-append the extension, so we need to do that manually
if (action == GtkFileChooserAction.Save) if (action == GtkFileChooserAction.Save)
{ {
var currentFilter = gtk_file_chooser_get_filter(dlg); var currentFilter = gtk_file_chooser_get_filter(dlg);
filtersDic.TryGetValue(currentFilter, out var selectedFilter); filtersDic.TryGetValue(currentFilter, out var selectedFilter);
for (var c = 0; c < result.Length; c++) for (var c = 0; c < result.Length; c++) { result[c] = StorageProviderHelpers.NameWithExtension(result[c], defaultExtension, selectedFilter); }
result[c] = NameWithExtension(result[c], defaultExtension, selectedFilter);
} }
} }
@ -98,13 +186,19 @@ namespace Avalonia.X11.NativeDialogs
action == GtkFileChooserAction.Save ? "Save" action == GtkFileChooserAction.Save ? "Save"
: action == GtkFileChooserAction.SelectFolder ? "Select" : action == GtkFileChooserAction.SelectFolder ? "Select"
: "Open")) : "Open"))
{
gtk_dialog_add_button(dlg, open, GtkResponseType.Accept); gtk_dialog_add_button(dlg, open, GtkResponseType.Accept);
}
using (var open = new Utf8Buffer("Cancel")) using (var open = new Utf8Buffer("Cancel"))
{
gtk_dialog_add_button(dlg, open, GtkResponseType.Cancel); gtk_dialog_add_button(dlg, open, GtkResponseType.Cancel);
}
if (initialDirectory != null) Uri? folderPath = null;
if (initialFolder?.TryGetUri(out folderPath) == true)
{ {
using var dir = new Utf8Buffer(initialDirectory); using var dir = new Utf8Buffer(folderPath.LocalPath);
gtk_file_chooser_set_current_folder(dlg, dir); gtk_file_chooser_set_current_folder(dlg, dir);
} }
@ -112,7 +206,7 @@ namespace Avalonia.X11.NativeDialogs
{ {
// gtk_file_chooser_set_filename() expects full path // gtk_file_chooser_set_filename() expects full path
using var fn = action == GtkFileChooserAction.Open using var fn = action == GtkFileChooserAction.Open
? new Utf8Buffer(Path.Combine(initialDirectory ?? "", initialFileName)) ? new Utf8Buffer(Path.Combine(folderPath?.LocalPath ?? "", initialFileName))
: new Utf8Buffer(initialFileName); : new Utf8Buffer(initialFileName);
if (action == GtkFileChooserAction.Save) if (action == GtkFileChooserAction.Save)
@ -131,84 +225,29 @@ namespace Avalonia.X11.NativeDialogs
return tcs.Task; return tcs.Task;
} }
string NameWithExtension(string path, string defaultExtension, FileDialogFilter filter) private async Task EnsureInitialized()
{ {
var name = Path.GetFileName(path); if (_initialized == null)
if (name != null && !name.Contains("."))
{ {
if (filter?.Extensions?.Count > 0) _initialized = StartGtk();
{
if (defaultExtension != null
&& filter.Extensions.Contains(defaultExtension))
return path + "." + defaultExtension.TrimStart('.');
var ext = filter.Extensions.FirstOrDefault(x => x != "*");
if (ext != null)
return path + "." + ext.TrimStart('.');
}
if (defaultExtension != null)
path += "." + defaultExtension.TrimStart('.');
} }
return path;
}
public async Task<string[]> ShowFileDialogAsync(FileDialog dialog, Window parent)
{
await EnsureInitialized();
var platformImpl = parent?.PlatformImpl;
return await await RunOnGlibThread(() => ShowDialog(
dialog.Title, platformImpl,
dialog is OpenFileDialog ? GtkFileChooserAction.Open : GtkFileChooserAction.Save,
(dialog as OpenFileDialog)?.AllowMultiple ?? false,
dialog.Directory,
dialog.InitialFileName,
dialog.Filters,
(dialog as SaveFileDialog)?.DefaultExtension,
(dialog as SaveFileDialog)?.ShowOverwritePrompt ?? false));
}
public async Task<string> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent)
{
await EnsureInitialized();
var platformImpl = parent?.PlatformImpl;
return await await RunOnGlibThread(async () =>
{
var res = await ShowDialog(
dialog.Title,
platformImpl,GtkFileChooserAction.SelectFolder,
false,
dialog.Directory,
null,
null,
null,
false);
return res?.FirstOrDefault();
});
}
async Task EnsureInitialized()
{
if (_initialized == null) _initialized = StartGtk();
if (!(await _initialized)) if (!(await _initialized))
{
throw new Exception("Unable to initialize GTK on separate thread"); throw new Exception("Unable to initialize GTK on separate thread");
}
} }
void UpdateParent(IntPtr chooser, IWindowImpl parentWindow) private static void UpdateParent(IntPtr chooser, IWindowImpl parentWindow)
{ {
var xid = parentWindow.Handle.Handle; var xid = parentWindow.Handle.Handle;
gtk_widget_realize(chooser); gtk_widget_realize(chooser);
var window = gtk_widget_get_window(chooser); var window = gtk_widget_get_window(chooser);
var parent = GetForeignWindow(xid); var parent = GetForeignWindow(xid);
if (window != IntPtr.Zero && parent != IntPtr.Zero) if (window != IntPtr.Zero && parent != IntPtr.Zero)
{
gdk_window_set_transient_for(window, parent); gdk_window_set_transient_for(window, parent);
}
} }
} }
} }

7
src/Avalonia.X11/X11Platform.cs

@ -80,7 +80,6 @@ namespace Avalonia.X11
.Bind<IClipboard>().ToConstant(new X11Clipboard(this)) .Bind<IClipboard>().ToConstant(new X11Clipboard(this))
.Bind<IPlatformSettings>().ToConstant(new PlatformSettingsStub()) .Bind<IPlatformSettings>().ToConstant(new PlatformSettingsStub())
.Bind<IPlatformIconLoader>().ToConstant(new X11IconLoader(Info)) .Bind<IPlatformIconLoader>().ToConstant(new X11IconLoader(Info))
.Bind<ISystemDialogImpl>().ToConstant(DBusSystemDialog.TryCreate() as ISystemDialogImpl ?? new ManagedFileDialogExtensions.ManagedSystemDialogImpl<Window>())
.Bind<IMountedVolumeInfoProvider>().ToConstant(new LinuxMountedVolumeInfoProvider()) .Bind<IMountedVolumeInfoProvider>().ToConstant(new LinuxMountedVolumeInfoProvider())
.Bind<IPlatformLifetimeEventsImpl>().ToConstant(new X11PlatformLifetimeEvents(this)); .Bind<IPlatformLifetimeEventsImpl>().ToConstant(new X11PlatformLifetimeEvents(this));
@ -214,6 +213,12 @@ namespace Avalonia
/// </summary> /// </summary>
public bool UseDBusMenu { get; set; } = true; public bool UseDBusMenu { get; set; } = true;
/// <summary>
/// Enables GTK file picker instead of default FreeDesktop.
/// The default value is true. And FreeDesktop file picker is used instead if available.
/// </summary>
public bool UseGtkFilePicker { get; set; } = false;
/// <summary> /// <summary>
/// Deferred renderer would be used when set to true. Immediate renderer when set to false. The default value is true. /// Deferred renderer would be used when set to true. Immediate renderer when set to false. The default value is true.
/// </summary> /// </summary>

9
src/Avalonia.X11/X11Window.cs

@ -17,6 +17,7 @@ using Avalonia.Input.TextInput;
using Avalonia.OpenGL; using Avalonia.OpenGL;
using Avalonia.OpenGL.Egl; using Avalonia.OpenGL.Egl;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Rendering; using Avalonia.Rendering;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.X11.Glx; using Avalonia.X11.Glx;
@ -28,7 +29,8 @@ namespace Avalonia.X11
unsafe partial class X11Window : IWindowImpl, IPopupImpl, IXI2Client, unsafe partial class X11Window : IWindowImpl, IPopupImpl, IXI2Client,
ITopLevelImplWithNativeMenuExporter, ITopLevelImplWithNativeMenuExporter,
ITopLevelImplWithNativeControlHost, ITopLevelImplWithNativeControlHost,
ITopLevelImplWithTextInputMethod ITopLevelImplWithTextInputMethod,
ITopLevelImplWithStorageProvider
{ {
private readonly AvaloniaX11Platform _platform; private readonly AvaloniaX11Platform _platform;
private readonly bool _popup; private readonly bool _popup;
@ -211,6 +213,10 @@ namespace Avalonia.X11
XChangeProperty(_x11.Display, _handle, _x11.Atoms._NET_WM_SYNC_REQUEST_COUNTER, XChangeProperty(_x11.Display, _handle, _x11.Atoms._NET_WM_SYNC_REQUEST_COUNTER,
_x11.Atoms.XA_CARDINAL, 32, PropertyMode.Replace, ref _xSyncCounter, 1); _x11.Atoms.XA_CARDINAL, 32, PropertyMode.Replace, ref _xSyncCounter, 1);
} }
var canUseFreeDekstopPicker = !platform.Options.UseGtkFilePicker && platform.Options.UseDBusMenu;
StorageProvider = canUseFreeDekstopPicker && DBusSystemDialog.TryCreate(Handle) is {} dBusStorage
? dBusStorage : new NativeDialogs.GtkSystemDialog(this);
} }
class SurfaceInfo : EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo class SurfaceInfo : EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo
@ -1192,6 +1198,7 @@ namespace Avalonia.X11
public bool NeedsManagedDecorations => false; public bool NeedsManagedDecorations => false;
public IStorageProvider StorageProvider { get; }
public class SurfacePlatformHandle : IPlatformNativeSurfaceHandle public class SurfacePlatformHandle : IPlatformNativeSurfaceHandle
{ {

15
src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs

@ -4,11 +4,15 @@ using Avalonia.Controls.Platform;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Input.Raw; using Avalonia.Input.Raw;
using Avalonia.Input.TextInput; using Avalonia.Input.TextInput;
using Avalonia.Platform.Storage;
using Avalonia.Rendering; using Avalonia.Rendering;
using Avalonia.Web.Blazor.Interop; using Avalonia.Web.Blazor.Interop;
using Avalonia.Web.Blazor.Interop.Storage;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop; using Microsoft.JSInterop;
using SkiaSharp; using SkiaSharp;
using HTMLPointerEventArgs = Microsoft.AspNetCore.Components.Web.PointerEventArgs; using HTMLPointerEventArgs = Microsoft.AspNetCore.Components.Web.PointerEventArgs;
@ -27,6 +31,7 @@ namespace Avalonia.Web.Blazor
private InputHelperInterop? _inputHelper = null; private InputHelperInterop? _inputHelper = null;
private InputHelperInterop? _canvasHelper = null; private InputHelperInterop? _canvasHelper = null;
private NativeControlHostInterop? _nativeControlHost = null; private NativeControlHostInterop? _nativeControlHost = null;
private StorageProviderInterop? _storageProvider = null;
private ElementReference _htmlCanvas; private ElementReference _htmlCanvas;
private ElementReference _inputElement; private ElementReference _inputElement;
private ElementReference _nativeControlsContainer; private ElementReference _nativeControlsContainer;
@ -59,7 +64,12 @@ namespace Avalonia.Web.Blazor
{ {
return _nativeControlHost ?? throw new InvalidOperationException("Blazor View wasn't initialized yet"); return _nativeControlHost ?? throw new InvalidOperationException("Blazor View wasn't initialized yet");
} }
internal IStorageProvider GetStorageProvider()
{
return _storageProvider ?? throw new InvalidOperationException("Blazor View wasn't initialized yet");
}
private void OnPointerCancel(HTMLPointerEventArgs e) private void OnPointerCancel(HTMLPointerEventArgs e)
{ {
if (e.PointerType == "touch") if (e.PointerType == "touch")
@ -243,7 +253,8 @@ namespace Avalonia.Web.Blazor
}; };
_nativeControlHost = await NativeControlHostInterop.ImportAsync(Js, _nativeControlsContainer); _nativeControlHost = await NativeControlHostInterop.ImportAsync(Js, _nativeControlsContainer);
_storageProvider = await StorageProviderInterop.ImportAsync(Js);
Console.WriteLine("starting html canvas setup"); Console.WriteLine("starting html canvas setup");
_interop = await SKHtmlCanvasInterop.ImportAsync(Js, _htmlCanvas, OnRenderFrame); _interop = await SKHtmlCanvasInterop.ImportAsync(Js, _htmlCanvas, OnRenderFrame);

6
src/Web/Avalonia.Web.Blazor/Interop/JSModuleInterop.cs

@ -37,6 +37,12 @@ namespace Avalonia.Web.Blazor.Interop
protected TValue Invoke<TValue>(string identifier, params object?[]? args) => protected TValue Invoke<TValue>(string identifier, params object?[]? args) =>
Module.Invoke<TValue>(identifier, args); Module.Invoke<TValue>(identifier, args);
protected ValueTask InvokeAsync(string identifier, params object?[]? args) =>
Module.InvokeVoidAsync(identifier, args);
protected ValueTask<TValue> InvokeAsync<TValue>(string identifier, params object?[]? args) =>
Module.InvokeAsync<TValue>(identifier, args);
protected virtual void OnDisposingModule() { } protected virtual void OnDisposingModule() { }
} }
} }

200
src/Web/Avalonia.Web.Blazor/Interop/Storage/StorageProviderInterop.cs

@ -0,0 +1,200 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Platform.Storage;
using Microsoft.JSInterop;
namespace Avalonia.Web.Blazor.Interop.Storage
{
internal record FilePickerAcceptType(string Description, IReadOnlyDictionary<string, IReadOnlyList<string>> Accept);
internal record FileProperties(ulong Size, long LastModified, string? Type);
internal class StorageProviderInterop : JSModuleInterop, IStorageProvider
{
private const string JsFilename = "./_content/Avalonia.Web.Blazor/StorageProvider.js";
private const string PickerCancelMessage = "The user aborted a request";
public static async Task<StorageProviderInterop> ImportAsync(IJSRuntime js)
{
var interop = new StorageProviderInterop(js);
await interop.ImportAsync();
return interop;
}
public StorageProviderInterop(IJSRuntime js)
: base(js, JsFilename)
{
}
public bool CanOpen => Invoke<bool>("StorageProvider.canOpen");
public bool CanSave => Invoke<bool>("StorageProvider.canSave");
public bool CanPickFolder => Invoke<bool>("StorageProvider.canPickFolder");
public async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
{
try
{
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle;
var (types, exludeAll) = ConvertFileTypes(options.FileTypeFilter);
var items = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.openFileDialog", startIn, options.AllowMultiple, types, exludeAll);
var count = items.Invoke<int>("count");
return Enumerable.Range(0, count)
.Select(index => new JSStorageFile(items.Invoke<IJSInProcessObjectReference>("at", index)))
.ToArray();
}
catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal))
{
return Array.Empty<IStorageFile>();
}
}
public async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
{
try
{
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle;
var (types, exludeAll) = ConvertFileTypes(options.FileTypeChoices);
var item = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.saveFileDialog", startIn, options.SuggestedFileName, types, exludeAll);
return item is not null ? new JSStorageFile(item) : null;
}
catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal))
{
return null;
}
}
public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{
try
{
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle;
var item = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.selectFolderDialog", startIn);
return item is not null ? new[] { new JSStorageFolder(item) } : Array.Empty<IStorageFolder>();
}
catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal))
{
return Array.Empty<IStorageFolder>();
}
}
public async Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark)
{
var item = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.openBookmark", bookmark);
return item is not null ? new JSStorageFile(item) : null;
}
public async Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark)
{
var item = await InvokeAsync<IJSInProcessObjectReference>("StorageProvider.openBookmark", bookmark);
return item is not null ? new JSStorageFolder(item) : null;
}
private static (FilePickerAcceptType[]? types, bool excludeAllOption) ConvertFileTypes(IEnumerable<FilePickerFileType>? input)
{
var types = input?
.Where(t => t.MimeTypes?.Any() == true && t != FilePickerFileTypes.All)
.Select(t => new FilePickerAcceptType(t.Name, t.MimeTypes!
.ToDictionary(m => m, _ => (IReadOnlyList<string>)Array.Empty<string>())))
.ToArray();
if (types?.Length == 0)
{
types = null;
}
var inlcudeAll = input?.Contains(FilePickerFileTypes.All) == true || types is null;
return (types, !inlcudeAll);
}
}
internal abstract class JSStorageItem : IStorageBookmarkItem
{
internal IJSInProcessObjectReference? _fileHandle;
protected JSStorageItem(IJSInProcessObjectReference fileHandle)
{
_fileHandle = fileHandle ?? throw new ArgumentNullException(nameof(fileHandle));
}
internal IJSInProcessObjectReference FileHandle => _fileHandle ?? throw new ObjectDisposedException(nameof(JSStorageItem));
public string Name => FileHandle.Invoke<string>("getName");
public bool TryGetUri([NotNullWhen(true)] out Uri? uri)
{
uri = new Uri(Name, UriKind.Relative);
return false;
}
public async Task<StorageItemProperties> GetBasicPropertiesAsync()
{
var properties = await FileHandle.InvokeAsync<FileProperties?>("getProperties");
return new StorageItemProperties(
properties?.Size,
dateCreated: null,
dateModified: properties?.LastModified > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(properties.LastModified) : null);
}
public bool CanBookmark => true;
public Task<string?> SaveBookmark()
{
return FileHandle.InvokeAsync<string?>("saveBookmark").AsTask();
}
public Task<IStorageFolder?> GetParentAsync()
{
return Task.FromResult<IStorageFolder?>(null);
}
public Task ReleaseBookmark()
{
return FileHandle.InvokeAsync<string?>("deleteBookmark").AsTask();
}
public void Dispose()
{
_fileHandle?.Dispose();
_fileHandle = null;
}
}
internal class JSStorageFile : JSStorageItem, IStorageBookmarkFile
{
public JSStorageFile(IJSInProcessObjectReference fileHandle) : base(fileHandle)
{
}
public bool CanOpenRead => true;
public async Task<Stream> OpenRead()
{
var stream = await FileHandle.InvokeAsync<IJSStreamReference>("openRead");
// Remove maxAllowedSize limit, as developer can decide if they read only small part or everything.
return await stream.OpenReadStreamAsync(long.MaxValue, CancellationToken.None);
}
public bool CanOpenWrite => true;
public async Task<Stream> OpenWrite()
{
var properties = await FileHandle.InvokeAsync<FileProperties?>("getProperties");
var streamWriter = await FileHandle.InvokeAsync<IJSInProcessObjectReference>("openWrite");
return new JSWriteableStream(streamWriter, (long)(properties?.Size ?? 0));
}
}
internal class JSStorageFolder : JSStorageItem, IStorageBookmarkFolder
{
public JSStorageFolder(IJSInProcessObjectReference fileHandle) : base(fileHandle)
{
}
}
}

124
src/Web/Avalonia.Web.Blazor/Interop/Storage/WriteableStream.cs

@ -0,0 +1,124 @@
using System.Buffers;
using System.Text.Json.Serialization;
using Microsoft.JSInterop;
namespace Avalonia.Web.Blazor.Interop.Storage
{
// Loose wrapper implementaion of a stream on top of FileAPI FileSystemWritableFileStream
internal sealed class JSWriteableStream : Stream
{
private IJSInProcessObjectReference? _jSReference;
// Unfortunatelly we can't read current length/position, so we need to keep it C#-side only.
private long _length, _position;
internal JSWriteableStream(IJSInProcessObjectReference jSReference, long initialLength)
{
_jSReference = jSReference;
_length = initialLength;
}
private IJSInProcessObjectReference JSReference => _jSReference ?? throw new ObjectDisposedException(nameof(JSWriteableStream));
public override bool CanRead => false;
public override bool CanSeek => true;
public override bool CanWrite => true;
public override long Length => _length;
public override long Position
{
get => _position;
set => Seek(_position, SeekOrigin.Begin);
}
public override void Flush()
{
// no-op
}
public override int Read(byte[] buffer, int offset, int count)
{
throw new NotSupportedException();
}
public override long Seek(long offset, SeekOrigin origin)
{
var position = origin switch
{
SeekOrigin.Current => _position + offset,
SeekOrigin.End => _length + offset,
_ => offset
};
JSReference.InvokeVoid("seek", position);
return position;
}
public override void SetLength(long value)
{
_length = value;
// See https://docs.w3cub.com/dom/filesystemwritablefilestream/truncate
// If the offset is smaller than the size, it remains unchanged. If the offset is larger than size, the offset is set to that size
if (_position > _length)
{
_position = _length;
}
JSReference.InvokeVoid("truncate", value);
}
public override void Write(byte[] buffer, int offset, int count)
{
throw new NotSupportedException("Synchronous writes are not supported.");
}
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
if (offset != 0 || count != buffer.Length)
{
// TODO, we need to pass prepared buffer to the JS
// Can't use ArrayPool as it can return bigger array than requested
// Can't use Span/Memory, as it's not supported by JS interop yet.
// Alternatively we can pass original buffer and offset+count, so it can be trimmed on the JS side (but is it more efficient tho?)
buffer = buffer.AsMemory(offset, count).ToArray();
}
return WriteAsyncInternal(buffer, cancellationToken).AsTask();
}
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
return WriteAsyncInternal(buffer.ToArray(), cancellationToken);
}
private ValueTask WriteAsyncInternal(byte[] buffer, CancellationToken _)
{
_position += buffer.Length;
return JSReference.InvokeVoidAsync("write", buffer);
}
protected override void Dispose(bool disposing)
{
if (_jSReference is { } jsReference)
{
_jSReference = null;
jsReference.InvokeVoid("close");
jsReference.Dispose();
}
}
public override async ValueTask DisposeAsync()
{
if (_jSReference is { } jsReference)
{
_jSReference = null;
await jsReference.InvokeVoidAsync("close");
await jsReference.DisposeAsync();
}
}
}
}

292
src/Web/Avalonia.Web.Blazor/Interop/Typescript/StorageProvider.ts

@ -0,0 +1,292 @@
// As we don't have proper package managing for Avalonia.Web project, declare types manually
declare global {
interface FileSystemWritableFileStream {
write(position: number, data: BufferSource | Blob | string): Promise<void>;
truncate(size: number): Promise<void>;
close(): Promise<void>;
}
type PermissionsMode = "read" | "readwrite";
interface FileSystemFileHandle {
name: string,
kind: "file" | "directory",
getFile(): Promise<File>;
createWritable(options?: { keepExistingData?: boolean }): Promise<FileSystemWritableFileStream>;
queryPermission(options?: { mode: PermissionsMode }): Promise<"granted" | "denied" | "prompt">;
requestPermission(options?: { mode: PermissionsMode }): Promise<"granted" | "denied" | "prompt">;
}
type WellKnownDirectory = "desktop" | "documents" | "downloads" | "music" | "pictures" | "videos";
type StartInDirectory = WellKnownDirectory | FileSystemFileHandle;
interface FilePickerAcceptType {
description: string,
// mime -> ext[] array
accept: { [mime: string]: string | string[] }
}
interface FilePickerOptions {
types?: FilePickerAcceptType[],
excludeAcceptAllOption: boolean,
id?: string,
startIn?: StartInDirectory
}
interface OpenFilePickerOptions extends FilePickerOptions {
multiple: boolean
}
interface SaveFilePickerOptions extends FilePickerOptions {
suggestedName?: string
}
interface DirectoryPickerOptions {
id?: string,
startIn?: StartInDirectory
}
interface Window {
showOpenFilePicker: (options: OpenFilePickerOptions) => Promise<FileSystemFileHandle[]>;
showSaveFilePicker: (options: SaveFilePickerOptions) => Promise<FileSystemFileHandle>;
showDirectoryPicker: (options: DirectoryPickerOptions) => Promise<FileSystemFileHandle>;
}
}
// TODO move to another file and use import
class IndexedDbWrapper {
constructor(private databaseName: string, private objectStores: [ string ]) {
}
public connect(): Promise<InnerDbConnection> {
var conn = window.indexedDB.open(this.databaseName, 1);
conn.onupgradeneeded = event => {
const db = (<IDBRequest<IDBDatabase>>event.target).result;
this.objectStores.forEach(store => {
db.createObjectStore(store);
});
}
return new Promise((resolve, reject) => {
conn.onsuccess = event => {
resolve(new InnerDbConnection((<IDBRequest<IDBDatabase>>event.target).result));
}
conn.onerror = event => {
reject((<IDBRequest<IDBDatabase>>event.target).error);
}
});
}
}
class InnerDbConnection {
constructor(private database: IDBDatabase) { }
private openStore(store: string, mode: IDBTransactionMode): IDBObjectStore {
const tx = this.database.transaction(store, mode);
return tx.objectStore(store);
}
public put(store: string, obj: any, key?: IDBValidKey): Promise<IDBValidKey> {
const os = this.openStore(store, "readwrite");
return new Promise((resolve, reject) => {
var response = os.put(obj, key);
response.onsuccess = () => {
resolve(response.result);
};
response.onerror = () => {
reject(response.error);
};
});
}
public get(store: string, key: IDBValidKey): any {
const os = this.openStore(store, "readonly");
return new Promise((resolve, reject) => {
var response = os.get(key);
response.onsuccess = () => {
resolve(response.result);
};
response.onerror = () => {
reject(response.error);
};
});
}
public delete(store: string, key: IDBValidKey): Promise<void> {
const os = this.openStore(store, "readwrite");
return new Promise((resolve, reject) => {
var response = os.delete(key);
response.onsuccess = () => {
resolve();
};
response.onerror = () => {
reject(response.error);
};
});
}
public close() {
this.database.close();
}
}
const fileBookmarksStore: string = "fileBookmarks";
const avaloniaDb = new IndexedDbWrapper("AvaloniaDb", [
fileBookmarksStore
])
class StorageItem {
constructor(private handle: FileSystemFileHandle, private bookmarkId?: string) { }
public getName(): string {
return this.handle.name
}
public async openRead(): Promise<Blob> {
await this.verityPermissions('read');
var file = await this.handle.getFile();
return file;
}
public async openWrite(): Promise<FileSystemWritableFileStream> {
await this.verityPermissions('readwrite');
return await this.handle.createWritable({ keepExistingData: true });
}
public async getProperties(): Promise<{ Size: number, LastModified: number, Type: string }> {
var file = this.handle.getFile && await this.handle.getFile();
return file && {
Size: file.size,
LastModified: file.lastModified,
Type: file.type
}
}
private async verityPermissions(mode: PermissionsMode): Promise<void | never> {
if (await this.handle.queryPermission({ mode }) === 'granted') {
return;
}
if (await this.handle.requestPermission({ mode }) === "denied") {
throw new Error("Read permissions denied");
}
}
public async saveBookmark(): Promise<string> {
// If file was previously bookmarked, just return old one.
if (this.bookmarkId) {
return this.bookmarkId;
}
const connection = await avaloniaDb.connect();
try {
const key = await connection.put(fileBookmarksStore, this.handle, this.generateBookmarkId());
return <string>key;
}
finally {
connection.close();
}
}
public async deleteBookmark(): Promise<void> {
if (!this.bookmarkId) {
return;
}
const connection = await avaloniaDb.connect();
try {
const key = await connection.delete(fileBookmarksStore, this.bookmarkId);
}
finally {
connection.close();
}
}
private generateBookmarkId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
}
class StorageItems {
constructor(private items: StorageItem[]) { }
public count(): number {
return this.items.length;
}
public at(index: number): StorageItem {
return this.items[index];
}
}
export class StorageProvider {
public static canOpen(): boolean {
return typeof window.showOpenFilePicker !== 'undefined';
}
public static canSave(): boolean {
return typeof window.showSaveFilePicker !== 'undefined';
}
public static canPickFolder(): boolean {
return typeof window.showDirectoryPicker !== 'undefined';
}
public static async selectFolderDialog(
startIn: StartInDirectory | null)
: Promise<StorageItem> {
// 'Picker' API doesn't accept "null" as a parameter, so it should be set to undefined.
const options: DirectoryPickerOptions = {
startIn: (startIn || undefined)
};
const handle = await window.showDirectoryPicker(options);
return new StorageItem(handle);
}
public static async openFileDialog(
startIn: StartInDirectory | null, multiple: boolean,
types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean)
: Promise<StorageItems> {
const options: OpenFilePickerOptions = {
startIn: (startIn || undefined),
multiple,
excludeAcceptAllOption,
types: (types || undefined)
};
const handles = await window.showOpenFilePicker(options);
return new StorageItems(handles.map(handle => new StorageItem(handle)));
}
public static async saveFileDialog(
startIn: StartInDirectory | null, suggestedName: string | null,
types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean)
: Promise<StorageItem> {
const options: SaveFilePickerOptions = {
startIn: (startIn || undefined),
suggestedName: (suggestedName || undefined),
excludeAcceptAllOption,
types: (types || undefined)
};
const handle = await window.showSaveFilePicker(options);
return new StorageItem(handle);
}
public static async openBookmark(key: string): Promise<StorageItem | null> {
const connection = await avaloniaDb.connect();
try {
const handle = await connection.get(fileBookmarksStore, key);
return handle && new StorageItem(handle, key);
}
finally {
connection.close();
}
}
}

4
src/Web/Avalonia.Web.Blazor/RazorViewTopLevelImpl.cs

@ -5,6 +5,7 @@ using Avalonia.Input;
using Avalonia.Input.Raw; using Avalonia.Input.Raw;
using Avalonia.Input.TextInput; using Avalonia.Input.TextInput;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Rendering; using Avalonia.Rendering;
using Avalonia.Web.Blazor.Interop; using Avalonia.Web.Blazor.Interop;
using SkiaSharp; using SkiaSharp;
@ -13,7 +14,7 @@ using SkiaSharp;
namespace Avalonia.Web.Blazor namespace Avalonia.Web.Blazor
{ {
internal class RazorViewTopLevelImpl : ITopLevelImplWithTextInputMethod, ITopLevelImplWithNativeControlHost internal class RazorViewTopLevelImpl : ITopLevelImplWithTextInputMethod, ITopLevelImplWithNativeControlHost, ITopLevelImplWithStorageProvider
{ {
private Size _clientSize; private Size _clientSize;
private IBlazorSkiaSurface? _currentSurface; private IBlazorSkiaSurface? _currentSurface;
@ -205,5 +206,6 @@ namespace Avalonia.Web.Blazor
public ITextInputMethodImpl TextInputMethod => _avaloniaView; public ITextInputMethodImpl TextInputMethod => _avaloniaView;
public INativeControlHostImpl? NativeControlHost => _avaloniaView.GetNativeControlHostImpl(); public INativeControlHostImpl? NativeControlHost => _avaloniaView.GetNativeControlHostImpl();
public IStorageProvider StorageProvider => _avaloniaView.GetStorageProvider();
} }
} }

9
src/Web/Avalonia.Web.Blazor/WinStubs.cs

@ -25,15 +25,6 @@ namespace Avalonia.Web.Blazor
public IWindowIconImpl LoadIcon(IBitmapImpl bitmap) => new IconStub(); public IWindowIconImpl LoadIcon(IBitmapImpl bitmap) => new IconStub();
} }
internal class SystemDialogsStub : ISystemDialogImpl
{
public Task<string[]?> ShowFileDialogAsync(FileDialog dialog, Window parent) =>
Task.FromResult((string[]?)null);
public Task<string?> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) =>
Task.FromResult((string?)null);
}
internal class ScreenStub : IScreenImpl internal class ScreenStub : IScreenImpl
{ {
public int ScreenCount => 1; public int ScreenCount => 1;

1
src/Web/Avalonia.Web.Blazor/WindowingPlatform.cs

@ -41,7 +41,6 @@ namespace Avalonia.Web.Blazor
.Bind<IPlatformThreadingInterface>().ToConstant(instance) .Bind<IPlatformThreadingInterface>().ToConstant(instance)
.Bind<IRenderLoop>().ToConstant(new RenderLoop()) .Bind<IRenderLoop>().ToConstant(new RenderLoop())
.Bind<IRenderTimer>().ToConstant(ManualTriggerRenderTimer.Instance) .Bind<IRenderTimer>().ToConstant(ManualTriggerRenderTimer.Instance)
.Bind<ISystemDialogImpl>().ToSingleton<SystemDialogsStub>()
.Bind<IWindowingPlatform>().ToConstant(instance) .Bind<IWindowingPlatform>().ToConstant(instance)
.Bind<IPlatformIconLoader>().ToSingleton<IconLoaderStub>() .Bind<IPlatformIconLoader>().ToSingleton<IconLoaderStub>()
.Bind<PlatformHotkeyConfiguration>().ToSingleton<PlatformHotkeyConfiguration>(); .Bind<PlatformHotkeyConfiguration>().ToSingleton<PlatformHotkeyConfiguration>();

1
src/Windows/Avalonia.Win32/Win32Platform.cs

@ -161,7 +161,6 @@ namespace Avalonia.Win32
.Bind<IPlatformThreadingInterface>().ToConstant(s_instance) .Bind<IPlatformThreadingInterface>().ToConstant(s_instance)
.Bind<IRenderLoop>().ToConstant(new RenderLoop()) .Bind<IRenderLoop>().ToConstant(new RenderLoop())
.Bind<IRenderTimer>().ToConstant(new DefaultRenderTimer(60)) .Bind<IRenderTimer>().ToConstant(new DefaultRenderTimer(60))
.Bind<ISystemDialogImpl>().ToSingleton<SystemDialogImpl>()
.Bind<IWindowingPlatform>().ToConstant(s_instance) .Bind<IWindowingPlatform>().ToConstant(s_instance)
.Bind<PlatformHotkeyConfiguration>().ToConstant(new PlatformHotkeyConfiguration(KeyModifiers.Control) .Bind<PlatformHotkeyConfiguration>().ToConstant(new PlatformHotkeyConfiguration(KeyModifiers.Control)
{ {

205
src/Windows/Avalonia.Win32/SystemDialogImpl.cs → src/Windows/Avalonia.Win32/Win32StorageProvider.cs

@ -1,86 +1,142 @@
#nullable enable #nullable enable
using System; using System;
using System.Linq;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.ComponentModel; using System.ComponentModel;
using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.Platform;
using Avalonia.MicroCom; using Avalonia.MicroCom;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using Avalonia.Win32.Interop; using Avalonia.Win32.Interop;
using Avalonia.Win32.Win32Com; using Avalonia.Win32.Win32Com;
namespace Avalonia.Win32 namespace Avalonia.Win32
{ {
internal class SystemDialogImpl : ISystemDialogImpl internal class Win32StorageProvider : BclStorageProvider
{ {
private const uint SIGDN_FILESYSPATH = 0x80058000; private const uint SIGDN_FILESYSPATH = 0x80058000;
private const FILEOPENDIALOGOPTIONS DefaultDialogOptions = FILEOPENDIALOGOPTIONS.FOS_FORCEFILESYSTEM | FILEOPENDIALOGOPTIONS.FOS_NOVALIDATE | private const FILEOPENDIALOGOPTIONS DefaultDialogOptions = FILEOPENDIALOGOPTIONS.FOS_FORCEFILESYSTEM | FILEOPENDIALOGOPTIONS.FOS_NOVALIDATE |
FILEOPENDIALOGOPTIONS.FOS_NOTESTFILECREATE | FILEOPENDIALOGOPTIONS.FOS_DONTADDTORECENT; FILEOPENDIALOGOPTIONS.FOS_NOTESTFILECREATE | FILEOPENDIALOGOPTIONS.FOS_DONTADDTORECENT;
public unsafe Task<string[]?> ShowFileDialogAsync(FileDialog dialog, Window parent) private readonly WindowImpl _windowImpl;
public Win32StorageProvider(WindowImpl windowImpl)
{
_windowImpl = windowImpl;
}
public override bool CanOpen => true;
public override bool CanSave => true;
public override bool CanPickFolder => true;
public override async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{
var files = await ShowFilePicker(
true, true,
options.AllowMultiple, false,
options.Title, null, options.SuggestedStartLocation, null, null);
return files.Select(f => new BclStorageFolder(new DirectoryInfo(f))).ToArray();
}
public override async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
{
var files = await ShowFilePicker(
true, false,
options.AllowMultiple, false,
options.Title, null, options.SuggestedStartLocation,
null, options.FileTypeFilter);
return files.Select(f => new BclStorageFile(new FileInfo(f))).ToArray();
}
public override async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
{
var files = await ShowFilePicker(
false, false,
false, options.ShowOverwritePrompt,
options.Title, options.SuggestedFileName, options.SuggestedStartLocation,
options.DefaultExtension, options.FileTypeChoices);
return files.Select(f => new BclStorageFile(new FileInfo(f))).FirstOrDefault();
}
private unsafe Task<IEnumerable<string>> ShowFilePicker(
bool isOpenFile,
bool openFolder,
bool allowMultiple,
bool? showOverwritePrompt,
string? title,
string? suggestedFileName,
IStorageFolder? folder,
string? defaultExtension,
IReadOnlyList<FilePickerFileType>? filters)
{ {
var hWnd = parent?.PlatformImpl?.Handle?.Handle ?? IntPtr.Zero;
return Task.Run(() => return Task.Run(() =>
{ {
string[]? result = default; IEnumerable<string> result = Array.Empty<string>();
try try
{ {
var clsid = dialog is OpenFileDialog ? UnmanagedMethods.ShellIds.OpenFileDialog : UnmanagedMethods.ShellIds.SaveFileDialog; var clsid = isOpenFile ? UnmanagedMethods.ShellIds.OpenFileDialog : UnmanagedMethods.ShellIds.SaveFileDialog;
var iid = UnmanagedMethods.ShellIds.IFileDialog; var iid = UnmanagedMethods.ShellIds.IFileDialog;
var frm = UnmanagedMethods.CreateInstance<IFileDialog>(ref clsid, ref iid); var frm = UnmanagedMethods.CreateInstance<IFileDialog>(ref clsid, ref iid);
var openDialog = dialog as OpenFileDialog;
var options = frm.Options; var options = frm.Options;
options |= DefaultDialogOptions; options |= DefaultDialogOptions;
if (openDialog?.AllowMultiple == true) if (openFolder)
{
options |= FILEOPENDIALOGOPTIONS.FOS_PICKFOLDERS;
}
if (allowMultiple)
{ {
options |= FILEOPENDIALOGOPTIONS.FOS_ALLOWMULTISELECT; options |= FILEOPENDIALOGOPTIONS.FOS_ALLOWMULTISELECT;
} }
if (dialog is SaveFileDialog saveFileDialog) if (showOverwritePrompt == false)
{ {
var overwritePrompt = saveFileDialog.ShowOverwritePrompt ?? true; options &= ~FILEOPENDIALOGOPTIONS.FOS_OVERWRITEPROMPT;
if (!overwritePrompt)
{
options &= ~FILEOPENDIALOGOPTIONS.FOS_OVERWRITEPROMPT;
}
} }
frm.SetOptions(options); frm.SetOptions(options);
var defaultExtension = (dialog as SaveFileDialog)?.DefaultExtension ?? ""; if (defaultExtension is not null)
fixed (char* pExt = defaultExtension)
{ {
frm.SetDefaultExtension(pExt); fixed (char* pExt = defaultExtension)
{
frm.SetDefaultExtension(pExt);
}
} }
var initialFileName = dialog.InitialFileName ?? ""; suggestedFileName ??= "";
fixed (char* fExt = initialFileName) fixed (char* fExt = suggestedFileName)
{ {
frm.SetFileName(fExt); frm.SetFileName(fExt);
} }
var title = dialog.Title ?? ""; title ??= "";
fixed (char* tExt = title) fixed (char* tExt = title)
{ {
frm.SetTitle(tExt); frm.SetTitle(tExt);
} }
fixed (void* pFilters = FiltersToPointer(dialog.Filters, out var count)) if (!openFolder)
{ {
frm.SetFileTypes((ushort)count, pFilters); fixed (void* pFilters = FiltersToPointer(filters, out var count))
{
frm.SetFileTypes((ushort)count, pFilters);
if (count > 0)
{
frm.SetFileTypeIndex(0);
}
}
} }
frm.SetFileTypeIndex(0); if (folder?.TryGetUri(out var folderPath) == true)
if (dialog.Directory != null)
{ {
var riid = UnmanagedMethods.ShellIds.IShellItem; var riid = UnmanagedMethods.ShellIds.IShellItem;
if (UnmanagedMethods.SHCreateItemFromParsingName(dialog.Directory, IntPtr.Zero, ref riid, out var directoryShellItem) if (UnmanagedMethods.SHCreateItemFromParsingName(folderPath.LocalPath, IntPtr.Zero, ref riid, out var directoryShellItem)
== (uint)UnmanagedMethods.HRESULT.S_OK) == (uint)UnmanagedMethods.HRESULT.S_OK)
{ {
var proxy = MicroComRuntime.CreateProxyFor<IShellItem>(directoryShellItem, true); var proxy = MicroComRuntime.CreateProxyFor<IShellItem>(directoryShellItem, true);
@ -89,18 +145,18 @@ namespace Avalonia.Win32
} }
} }
var showResult = frm.Show(hWnd); var showResult = frm.Show(_windowImpl.Handle!.Handle);
if ((uint)showResult == (uint)UnmanagedMethods.HRESULT.E_CANCELLED) if ((uint)showResult == (uint)UnmanagedMethods.HRESULT.E_CANCELLED)
{ {
return result; return result;
} }
else if ((uint)showResult != (uint)UnmanagedMethods.HRESULT.S_OK) else if ((uint)showResult != (uint)UnmanagedMethods.HRESULT.S_OK)
{ {
throw new Win32Exception(showResult); throw new Win32Exception(showResult);
} }
if (openDialog?.AllowMultiple == true) if (allowMultiple)
{ {
using var fileOpenDialog = frm.QueryInterface<IFileOpenDialog>(); using var fileOpenDialog = frm.QueryInterface<IFileOpenDialog>();
var shellItemArray = fileOpenDialog.Results; var shellItemArray = fileOpenDialog.Results;
@ -115,7 +171,8 @@ namespace Avalonia.Win32
results.Add(selected); results.Add(selected);
} }
} }
result = results.ToArray();
result = results;
} }
else if (frm.Result is { } shellItem else if (frm.Result is { } shellItem
&& GetAbsoluteFilePath(shellItem) is { } singleResult) && GetAbsoluteFilePath(shellItem) is { } singleResult)
@ -127,71 +184,14 @@ namespace Avalonia.Win32
} }
catch (COMException ex) catch (COMException ex)
{ {
throw new Win32Exception(ex.HResult); var message = new Win32Exception(ex.HResult).Message;
throw new COMException(message, ex);
} }
})!; })!;
} }
public unsafe Task<string?> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent)
{
return Task.Run(() =>
{
string? result = default;
try
{
var hWnd = parent?.PlatformImpl?.Handle?.Handle ?? IntPtr.Zero;
var clsid = UnmanagedMethods.ShellIds.OpenFileDialog;
var iid = UnmanagedMethods.ShellIds.IFileDialog;
var frm = UnmanagedMethods.CreateInstance<IFileDialog>(ref clsid, ref iid);
var options = frm.Options;
options = FILEOPENDIALOGOPTIONS.FOS_PICKFOLDERS | DefaultDialogOptions;
frm.SetOptions(options);
var title = dialog.Title ?? ""; private static unsafe string? GetAbsoluteFilePath(IShellItem shellItem)
fixed (char* tExt = title)
{
frm.SetTitle(tExt);
}
if (dialog.Directory != null)
{
var riid = UnmanagedMethods.ShellIds.IShellItem;
if (UnmanagedMethods.SHCreateItemFromParsingName(dialog.Directory, IntPtr.Zero, ref riid, out var directoryShellItem)
== (uint)UnmanagedMethods.HRESULT.S_OK)
{
var proxy = MicroComRuntime.CreateProxyFor<IShellItem>(directoryShellItem, true);
frm.SetFolder(proxy);
frm.SetDefaultFolder(proxy);
}
}
var showResult = frm.Show(hWnd);
if ((uint)showResult == (uint)UnmanagedMethods.HRESULT.E_CANCELLED)
{
return result;
}
else if ((uint)showResult != (uint)UnmanagedMethods.HRESULT.S_OK)
{
throw new Win32Exception(showResult);
}
if (frm.Result is not null)
{
result = GetAbsoluteFilePath(frm.Result);
}
return result;
}
catch (COMException ex)
{
throw new Win32Exception(ex.HResult);
}
});
}
private unsafe string? GetAbsoluteFilePath(IShellItem shellItem)
{ {
var pszString = new IntPtr(shellItem.GetDisplayName(SIGDN_FILESYSPATH)); var pszString = new IntPtr(shellItem.GetDisplayName(SIGDN_FILESYSPATH));
if (pszString != IntPtr.Zero) if (pszString != IntPtr.Zero)
@ -208,13 +208,13 @@ namespace Avalonia.Win32
return default; return default;
} }
private unsafe byte[] FiltersToPointer(List<FileDialogFilter>? filters, out int lenght) private static byte[] FiltersToPointer(IReadOnlyList<FilePickerFileType>? filters, out int length)
{ {
if (filters == null || filters.Count == 0) if (filters == null || filters.Count == 0)
{ {
filters = new List<FileDialogFilter> filters = new List<FilePickerFileType>
{ {
new FileDialogFilter { Name = "All files", Extensions = new List<string> { "*" } } FilePickerFileTypes.All
}; };
} }
@ -225,13 +225,18 @@ namespace Avalonia.Win32
for (int i = 0; i < filters.Count; i++) for (int i = 0; i < filters.Count; i++)
{ {
var filter = filters[i]; var filter = filters[i];
if (filter.Patterns is null || !filter.Patterns.Any())
{
continue;
}
var filterPtr = Marshal.AllocHGlobal(size); var filterPtr = Marshal.AllocHGlobal(size);
try try
{ {
var filterStr = new UnmanagedMethods.COMDLG_FILTERSPEC var filterStr = new UnmanagedMethods.COMDLG_FILTERSPEC
{ {
pszName = filter.Name ?? string.Empty, pszName = filter.Name ?? string.Empty,
pszSpec = string.Join(";", filter.Extensions.Select(e => "*." + e)) pszSpec = string.Join(";", filter.Patterns)
}; };
Marshal.StructureToPtr(filterStr, filterPtr, false); Marshal.StructureToPtr(filterStr, filterPtr, false);
@ -243,7 +248,7 @@ namespace Avalonia.Win32
} }
} }
lenght = filters.Count; length = filters.Count;
return resultArr; return resultArr;
} }
} }

7
src/Windows/Avalonia.Win32/WindowImpl.cs

@ -24,6 +24,7 @@ using Avalonia.Win32.WinRT.Composition;
using static Avalonia.Win32.Interop.UnmanagedMethods; using static Avalonia.Win32.Interop.UnmanagedMethods;
using Avalonia.Collections.Pooled; using Avalonia.Collections.Pooled;
using Avalonia.Metadata; using Avalonia.Metadata;
using Avalonia.Platform.Storage;
namespace Avalonia.Win32 namespace Avalonia.Win32
{ {
@ -33,7 +34,8 @@ namespace Avalonia.Win32
[Unstable] [Unstable]
public partial class WindowImpl : IWindowImpl, EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo, public partial class WindowImpl : IWindowImpl, EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo,
ITopLevelImplWithNativeControlHost, ITopLevelImplWithNativeControlHost,
ITopLevelImplWithTextInputMethod ITopLevelImplWithTextInputMethod,
ITopLevelImplWithStorageProvider
{ {
private static readonly List<WindowImpl> s_instances = new List<WindowImpl>(); private static readonly List<WindowImpl> s_instances = new List<WindowImpl>();
@ -164,6 +166,7 @@ namespace Avalonia.Win32
} }
Screen = new ScreenImpl(); Screen = new ScreenImpl();
StorageProvider = new Win32StorageProvider(this);
_nativeControlHost = new Win32NativeControlHost(this, _isUsingComposition); _nativeControlHost = new Win32NativeControlHost(this, _isUsingComposition);
s_instances.Add(this); s_instances.Add(this);
@ -1421,6 +1424,8 @@ namespace Avalonia.Win32
public ITextInputMethodImpl TextInputMethod => Imm32InputMethod.Current; public ITextInputMethodImpl TextInputMethod => Imm32InputMethod.Current;
public IStorageProvider StorageProvider { get; }
private class WindowImplPlatformHandle : IPlatformNativeSurfaceHandle private class WindowImplPlatformHandle : IPlatformNativeSurfaceHandle
{ {
private readonly WindowImpl _owner; private readonly WindowImpl _owner;

9
src/iOS/Avalonia.iOS/AvaloniaView.cs

@ -6,7 +6,9 @@ using Avalonia.Controls.Platform;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Input.Raw; using Avalonia.Input.Raw;
using Avalonia.Input.TextInput; using Avalonia.Input.TextInput;
using Avalonia.iOS.Storage;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Rendering; using Avalonia.Rendering;
using CoreAnimation; using CoreAnimation;
using Foundation; using Foundation;
@ -41,9 +43,10 @@ namespace Avalonia.iOS
); );
_topLevelImpl.Surfaces = new[] {new EaglLayerSurface(l)}; _topLevelImpl.Surfaces = new[] {new EaglLayerSurface(l)};
MultipleTouchEnabled = true; MultipleTouchEnabled = true;
AddSubviews(new UIView[] { new UIKit.UIButton(UIButtonType.InfoDark) });
} }
internal class TopLevelImpl : ITopLevelImplWithTextInputMethod, ITopLevelImplWithNativeControlHost internal class TopLevelImpl : ITopLevelImplWithTextInputMethod, ITopLevelImplWithNativeControlHost, ITopLevelImplWithStorageProvider
{ {
private readonly AvaloniaView _view; private readonly AvaloniaView _view;
public AvaloniaView View => _view; public AvaloniaView View => _view;
@ -52,6 +55,7 @@ namespace Avalonia.iOS
{ {
_view = view; _view = view;
NativeControlHost = new NativeControlHostImpl(_view); NativeControlHost = new NativeControlHostImpl(_view);
StorageProvider = new IOSStorageProvider(view);
} }
public void Dispose() public void Dispose()
@ -113,7 +117,8 @@ namespace Avalonia.iOS
new AcrylicPlatformCompensationLevels(); new AcrylicPlatformCompensationLevels();
public ITextInputMethodImpl? TextInputMethod => _view; public ITextInputMethodImpl? TextInputMethod => _view;
public INativeControlHostImpl NativeControlHost { get; } public INativeControlHostImpl NativeControlHost { get; }
public IStorageProvider StorageProvider { get; }
} }
[Export("layerClass")] [Export("layerClass")]

66
src/iOS/Avalonia.iOS/Storage/IOSSecurityScopedStream.cs

@ -0,0 +1,66 @@
using System.IO;
using Foundation;
using UIKit;
#nullable enable
namespace Avalonia.iOS.Storage;
internal sealed class IOSSecurityScopedStream : Stream
{
private readonly UIDocument _document;
private readonly FileStream _stream;
private readonly NSUrl _url;
internal IOSSecurityScopedStream(NSUrl url, FileAccess access)
{
_document = new UIDocument(url);
var path = _document.FileUrl.Path;
_url = url;
_url.StartAccessingSecurityScopedResource();
_stream = File.Open(path, FileMode.Open, access);
}
public override bool CanRead => _stream.CanRead;
public override bool CanSeek => _stream.CanSeek;
public override bool CanWrite => _stream.CanWrite;
public override long Length => _stream.Length;
public override long Position
{
get => _stream.Position;
set => _stream.Position = value;
}
public override void Flush() =>
_stream.Flush();
public override int Read(byte[] buffer, int offset, int count) =>
_stream.Read(buffer, offset, count);
public override long Seek(long offset, SeekOrigin origin) =>
_stream.Seek(offset, origin);
public override void SetLength(long value) =>
_stream.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) =>
_stream.Write(buffer, offset, count);
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
_stream.Dispose();
_document.Dispose();
_url.StopAccessingSecurityScopedResource();
}
}
}

121
src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs

@ -0,0 +1,121 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Logging;
using Avalonia.Platform.Storage;
using Foundation;
using UIKit;
#nullable enable
namespace Avalonia.iOS.Storage;
internal abstract class IOSStorageItem : IStorageBookmarkItem
{
private readonly string _filePath;
protected IOSStorageItem(NSUrl url)
{
Url = url ?? throw new ArgumentNullException(nameof(url));
using (var doc = new UIDocument(url))
{
_filePath = doc.FileUrl?.Path ?? url.FilePathUrl.Path;
Name = doc.LocalizedName ?? Path.GetFileName(_filePath) ?? url.FilePathUrl.LastPathComponent;
}
}
internal NSUrl Url { get; }
public bool CanBookmark => true;
public string Name { get; }
public Task<StorageItemProperties> GetBasicPropertiesAsync()
{
var attributes = NSFileManager.DefaultManager.GetAttributes(_filePath, out var error);
if (error is not null)
{
Logger.TryGet(LogEventLevel.Error, LogArea.IOSPlatform)?.
Log(this, "GetBasicPropertiesAsync returned an error: {ErrorCode} {ErrorMessage}", error.Code, error.LocalizedFailureReason);
}
return Task.FromResult(new StorageItemProperties(attributes?.Size, (DateTime)attributes?.CreationDate, (DateTime)attributes?.ModificationDate));
}
public Task<IStorageFolder?> GetParentAsync()
{
return Task.FromResult<IStorageFolder?>(new IOSStorageFolder(Url.RemoveLastPathComponent()));
}
public Task ReleaseBookmark()
{
// no-op
return Task.CompletedTask;
}
public Task<string?> SaveBookmark()
{
try
{
if (!Url.StartAccessingSecurityScopedResource())
{
return Task.FromResult<string?>(null);
}
var newBookmark = Url.CreateBookmarkData(NSUrlBookmarkCreationOptions.SuitableForBookmarkFile, Array.Empty<string>(), null, out var bookmarkError);
if (bookmarkError is not null)
{
Logger.TryGet(LogEventLevel.Error, LogArea.IOSPlatform)?.
Log(this, "SaveBookmark returned an error: {ErrorCode} {ErrorMessage}", bookmarkError.Code, bookmarkError.LocalizedFailureReason);
return Task.FromResult<string?>(null);
}
return Task.FromResult<string?>(
newBookmark.GetBase64EncodedString(NSDataBase64EncodingOptions.None));
}
finally
{
Url.StopAccessingSecurityScopedResource();
}
}
public bool TryGetUri([NotNullWhen(true)] out Uri uri)
{
uri = Url;
return uri is not null;
}
public void Dispose()
{
}
}
internal sealed class IOSStorageFile : IOSStorageItem, IStorageBookmarkFile
{
public IOSStorageFile(NSUrl url) : base(url)
{
}
public bool CanOpenRead => true;
public bool CanOpenWrite => true;
public Task<Stream> OpenRead()
{
return Task.FromResult<Stream>(new IOSSecurityScopedStream(Url, FileAccess.Read));
}
public Task<Stream> OpenWrite()
{
return Task.FromResult<Stream>(new IOSSecurityScopedStream(Url, FileAccess.Write));
}
}
internal sealed class IOSStorageFolder : IOSStorageItem, IStorageBookmarkFolder
{
public IOSStorageFolder(NSUrl url) : base(url)
{
}
}

212
src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs

@ -0,0 +1,212 @@
using System;
using System.Linq;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
using Avalonia.Logging;
using Avalonia.Platform.Storage;
using UIKit;
using Foundation;
using UniformTypeIdentifiers;
using UTTypeLegacy = MobileCoreServices.UTType;
using UTType = UniformTypeIdentifiers.UTType;
#nullable enable
namespace Avalonia.iOS.Storage;
internal class IOSStorageProvider : IStorageProvider
{
private readonly AvaloniaView _view;
public IOSStorageProvider(AvaloniaView view)
{
_view = view;
}
public bool CanOpen => true;
public bool CanSave => false;
public bool CanPickFolder => true;
public async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
{
UIDocumentPickerViewController documentPicker;
if (OperatingSystem.IsIOSVersionAtLeast(14))
{
var allowedUtis = options.FileTypeFilter?.SelectMany(f =>
{
// We check for OS version outside of the lambda, it's safe.
#pragma warning disable CA1416
if (f.AppleUniformTypeIdentifiers?.Any() == true)
{
return f.AppleUniformTypeIdentifiers.Select(id => UTType.CreateFromIdentifier(id));
}
if (f.MimeTypes?.Any() == true)
{
return f.MimeTypes.Select(id => UTType.CreateFromMimeType(id));
}
return Array.Empty<UTType>();
#pragma warning restore CA1416
})
.Where(id => id is not null)
.ToArray() ?? new[]
{
UTTypes.Content,
UTTypes.Item,
UTTypes.Data
};
documentPicker = new UIDocumentPickerViewController(allowedUtis!, false);
}
else
{
var allowedUtis = options.FileTypeFilter?.SelectMany(f => f.AppleUniformTypeIdentifiers ?? Array.Empty<string>())
.ToArray() ?? new[]
{
UTTypeLegacy.Content,
UTTypeLegacy.Item,
"public.data"
};
documentPicker = new UIDocumentPickerViewController(allowedUtis, UIDocumentPickerMode.Open);
}
using (documentPicker)
{
if (OperatingSystem.IsIOSVersionAtLeast(13))
{
documentPicker.DirectoryUrl = GetUrlFromFolder(options.SuggestedStartLocation);
}
if (OperatingSystem.IsIOSVersionAtLeast(11, 0))
{
documentPicker.AllowsMultipleSelection = options.AllowMultiple;
}
var urls = await ShowPicker(documentPicker);
return urls.Select(u => new IOSStorageFile(u)).ToArray();
}
}
public Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark)
{
return Task.FromResult<IStorageBookmarkFile?>(GetBookmarkedUrl(bookmark) is { } url
? new IOSStorageFile(url) : null);
}
public Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark)
{
return Task.FromResult<IStorageBookmarkFolder?>(GetBookmarkedUrl(bookmark) is { } url
? new IOSStorageFolder(url) : null);
}
public Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
{
return Task.FromException<IStorageFile?>(
new PlatformNotSupportedException("Save file picker is not supported by iOS"));
}
public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{
using var documentPicker = OperatingSystem.IsIOSVersionAtLeast(14) ?
new UIDocumentPickerViewController(new[] { UTTypes.Folder }, false) :
new UIDocumentPickerViewController(new string[] { UTTypeLegacy.Folder }, UIDocumentPickerMode.Open);
if (OperatingSystem.IsIOSVersionAtLeast(13))
{
documentPicker.DirectoryUrl = GetUrlFromFolder(options.SuggestedStartLocation);
}
if (OperatingSystem.IsIOSVersionAtLeast(11))
{
documentPicker.AllowsMultipleSelection = options.AllowMultiple;
}
var urls = await ShowPicker(documentPicker);
return urls.Select(u => new IOSStorageFolder(u)).ToArray();
}
private static NSUrl? GetUrlFromFolder(IStorageFolder? folder)
{
if (folder is IOSStorageFolder iosFolder)
{
return iosFolder.Url;
}
if (folder?.TryGetUri(out var fullPath) == true)
{
return fullPath;
}
return null;
}
private Task<NSUrl[]> ShowPicker(UIDocumentPickerViewController documentPicker)
{
var tcs = new TaskCompletionSource<NSUrl[]>();
documentPicker.Delegate = new PickerDelegate(urls => tcs.TrySetResult(urls));
if (documentPicker.PresentationController != null)
{
documentPicker.PresentationController.Delegate =
new UIPresentationControllerDelegate(() => tcs.TrySetResult(Array.Empty<NSUrl>()));
}
var controller = _view.Window?.RootViewController ?? throw new InvalidOperationException("RootViewController wasn't initialized");
controller.PresentViewController(documentPicker, true, null);
return tcs.Task;
}
private NSUrl? GetBookmarkedUrl(string bookmark)
{
var url = NSUrl.FromBookmarkData(new NSData(bookmark, NSDataBase64DecodingOptions.None),
NSUrlBookmarkResolutionOptions.WithoutUI, null, out var isStale, out var error);
if (isStale)
{
Logger.TryGet(LogEventLevel.Warning, LogArea.IOSPlatform)?.Log(this, "Stale bookmark detected");
}
if (error != null)
{
throw new NSErrorException(error);
}
return url;
}
private class PickerDelegate : UIDocumentPickerDelegate
{
private readonly Action<NSUrl[]>? _pickHandler;
internal PickerDelegate(Action<NSUrl[]> pickHandler)
=> _pickHandler = pickHandler;
public override void WasCancelled(UIDocumentPickerViewController controller)
=> _pickHandler?.Invoke(Array.Empty<NSUrl>());
public override void DidPickDocument(UIDocumentPickerViewController controller, NSUrl[] urls)
=> _pickHandler?.Invoke(urls);
public override void DidPickDocument(UIDocumentPickerViewController controller, NSUrl url)
=> _pickHandler?.Invoke(new[] { url });
}
private class UIPresentationControllerDelegate : UIAdaptivePresentationControllerDelegate
{
private Action? _dismissHandler;
internal UIPresentationControllerDelegate(Action dismissHandler)
=> this._dismissHandler = dismissHandler;
public override void DidDismiss(UIPresentationController presentationController)
{
_dismissHandler?.Invoke();
_dismissHandler = null;
}
protected override void Dispose(bool disposing)
{
_dismissHandler?.Invoke();
base.Dispose(disposing);
}
}
}
Loading…
Cancel
Save