Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4d9487c
Test file saver with export to service mode
matt-goldman Dec 1, 2025
ed5f254
Used correct constructor with asCopy set to true
matt-goldman Dec 1, 2025
47ddfe5
Add temporary directory cleanup in file saving process
matt-goldman Dec 2, 2025
07be3c6
Fixes disposal of temporary file on iOS
VladislavAntonyuk Dec 4, 2025
7757fce
Merge branch 'main' into fix-macios-filepicker
TheCodeTraveler Dec 4, 2025
f87e43d
Fixes disposal of temporary directory on iOS
VladislavAntonyuk Dec 4, 2025
08f9ffd
Update src/CommunityToolkit.Maui.Core/Essentials/FileSaver/FileSaverI…
matt-goldman Dec 6, 2025
102300e
Merge branch 'main' into fix-macios-filepicker
matt-goldman Dec 6, 2025
d88f395
Merge branch 'main' into fix-macios-filepicker
matt-goldman Dec 8, 2025
9b6b6ad
Merge branch 'main' into fix-macios-filepicker
VladislavAntonyuk Dec 17, 2025
2f2189a
Refactor temp file cleanup in FileSaver (macOS/iOS)
VladislavAntonyuk Dec 17, 2025
fc1e04a
Apply suggestion from @Copilot
VladislavAntonyuk Dec 17, 2025
b2a825f
Ensure resources are only created if UI is available, improve error m…
matt-goldman Dec 19, 2025
2184f9f
Move dispose to finally
VladislavAntonyuk Dec 21, 2025
0d1f058
fix naming
VladislavAntonyuk Dec 21, 2025
27ebae6
fix order
VladislavAntonyuk Dec 21, 2025
e77d60e
check for index
VladislavAntonyuk Dec 21, 2025
9d68b27
Merge branch 'main' into fix-macios-filepicker
VladislavAntonyuk Dec 21, 2025
17c909d
rework File/FolderPicker. Removed IDisposable, Added IsCancelled
VladislavAntonyuk Dec 23, 2025
7d7f4ec
Merge branch 'main' into fix-macios-filepicker
VladislavAntonyuk Jan 6, 2026
1796b70
Remove `ConfigureAwait(false)`, Implement CancellationToken, Implemen…
TheCodeTraveler Jan 8, 2026
02c045d
Remove invalid `MemberNotNull`s
TheCodeTraveler Jan 8, 2026
e3f66e2
Use `ExceptionDispatchInfo.Throw` to improve exception handling
TheCodeTraveler Jan 8, 2026
88dac83
Remove `ConfigureAwait(false)`, Use `WaitAsync()`
TheCodeTraveler Jan 8, 2026
8525ffa
Remove invalid `MemberNotNull` attributes, Use `ExceptionDispatchInfo…
TheCodeTraveler Jan 8, 2026
11053c0
Use `FolderPickerException`
TheCodeTraveler Jan 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,6 @@ async Task SaveFileInstance(CancellationToken cancellationToken)
fileSaverResult.EnsureSuccess();

await Toast.Make($"File is saved: {fileSaverResult.FilePath}").Show(cancellationToken);
#if IOS || MACCATALYST
fileSaverInstance.Dispose();
#endif
}
catch (Exception ex)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,6 @@ async Task PickFolderInstance(CancellationToken cancellationToken)
folderPickerResult.EnsureSuccess();

await Toast.Make($"Folder picked: Name - {folderPickerResult.Folder.Name}, Path - {folderPickerResult.Folder.Path}", ToastDuration.Long).Show(cancellationToken);
#if IOS || MACCATALYST
folderPickerInstance.Dispose();
#endif
}
catch (Exception e)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,85 +6,93 @@ namespace CommunityToolkit.Maui.Storage;
/// <inheritdoc cref="IFileSaver" />
[SupportedOSPlatform("iOS14.0")]
[SupportedOSPlatform("MacCatalyst14.0")]
public sealed partial class FileSaverImplementation : IFileSaver, IDisposable
public sealed partial class FileSaverImplementation : IFileSaver
{
UIDocumentPickerViewController? documentPickerViewController;
TaskCompletionSource<string>? taskCompetedSource;

/// <inheritdoc />
public void Dispose()
Task<string> InternalSaveAsync(string fileName, Stream stream, IProgress<double>? progress, CancellationToken cancellationToken)
{
InternalDispose();
return InternalSaveAsync("/", fileName, stream, progress, cancellationToken);
}

async Task<string> InternalSaveAsync(string initialPath, string fileName, Stream stream, IProgress<double>? progress, CancellationToken cancellationToken)

async Task<string> InternalSaveAsync(
string initialPath,
string fileName,
Stream stream,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

var currentViewController = Platform.GetCurrentUIViewController()
?? throw new FileSaveException(
"Cannot present file picker: No active view controller found. Ensure the app is active with a visible window.");

var fileManager = NSFileManager.DefaultManager;
var tempDirectoryPath = fileManager.GetTemporaryDirectory().Append(Guid.NewGuid().ToString(), true);
var isDirectoryCreated = fileManager.CreateDirectory(tempDirectoryPath, true, null, out var error);
if (!isDirectoryCreated)

var tempDirectoryPath = fileManager
.GetTemporaryDirectory()
.Append(Guid.NewGuid().ToString(), true);

if (!fileManager.CreateDirectory(tempDirectoryPath, true, null, out var error))
{
throw new FileSaveException(error?.LocalizedDescription ?? "Unable to create temp directory.");
}

var fileUrl = tempDirectoryPath.Append(fileName, false);
await WriteStream(stream, fileUrl.Path ?? throw new Exception("Path cannot be null."), progress, cancellationToken);

cancellationToken.ThrowIfCancellationRequested();
taskCompetedSource?.TrySetCanceled(CancellationToken.None);
var tcs = taskCompetedSource = new(cancellationToken);
await WriteStream(
stream,
fileUrl.Path ?? throw new FileSaveException("Path cannot be null."),
progress,
cancellationToken);

documentPickerViewController = new([fileUrl])
{
DirectoryUrl = NSUrl.FromString(initialPath)
};
documentPickerViewController.DidPickDocumentAtUrls += DocumentPickerViewControllerOnDidPickDocumentAtUrls;
documentPickerViewController.WasCancelled += DocumentPickerViewControllerOnWasCancelled;
var tcs = new TaskCompletionSource<string>(
TaskCreationOptions.RunContinuationsAsynchronously);

var currentViewController = Platform.GetCurrentUIViewController();
if (currentViewController is not null)
{
currentViewController.PresentViewController(documentPickerViewController, true, null);
}
else
{
throw new FileSaveException("Unable to get a window where to present the file saver UI.");
}
await using var registration = cancellationToken.Register(() =>
tcs.TrySetCanceled(cancellationToken));

return await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
}
using var picker = new UIDocumentPickerViewController([fileUrl], true);
picker.DirectoryUrl = NSUrl.FromString(initialPath);

Task<string> InternalSaveAsync(string fileName, Stream stream, IProgress<double>? progress, CancellationToken cancellationToken)
{
return InternalSaveAsync("/", fileName, stream, progress, cancellationToken);
}

void DocumentPickerViewControllerOnWasCancelled(object? sender, EventArgs e)
{
taskCompetedSource?.TrySetException(new FileSaveException("Operation cancelled."));
InternalDispose();
}
picker.DidPickDocumentAtUrls += OnPicked;
picker.WasCancelled += OnCancelled;

void DocumentPickerViewControllerOnDidPickDocumentAtUrls(object? sender, UIDocumentPickedAtUrlsEventArgs e)
{
try
{
taskCompetedSource?.TrySetResult(e.Urls[0].Path ?? throw new FileSaveException("Unable to retrieve the path of the saved file."));
cancellationToken.ThrowIfCancellationRequested();
currentViewController.PresentViewController(picker, true, null);

return await tcs.Task.WaitAsync(cancellationToken);
}
finally
{
InternalDispose();
fileManager.Remove(tempDirectoryPath, out _);

picker.DidPickDocumentAtUrls -= OnPicked;
picker.WasCancelled -= OnCancelled;
}

void OnPicked(object? sender, UIDocumentPickedAtUrlsEventArgs e)
{
if (e.Urls.Length is 0)
{
tcs.TrySetException(new FileSaveException("No file was selected."));
return;
}

var path = e.Urls[0].Path;
if (path is null)
{
tcs.TrySetException(new FileSaveException("File path cannot be null."));
return;
}

tcs.TrySetResult(path);
}
}

void InternalDispose()
{
if (documentPickerViewController is not null)
void OnCancelled(object? sender, EventArgs e)
{
documentPickerViewController.DidPickDocumentAtUrls -= DocumentPickerViewControllerOnDidPickDocumentAtUrls;
documentPickerViewController.WasCancelled -= DocumentPickerViewControllerOnWasCancelled;
documentPickerViewController.Dispose();
tcs.TrySetCanceled(cancellationToken);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.ExceptionServices;

namespace CommunityToolkit.Maui.Storage;

Expand All @@ -15,6 +16,11 @@ public record FileSaverResult(string? FilePath, Exception? Exception)
[MemberNotNullWhen(true, nameof(FilePath))]
[MemberNotNullWhen(false, nameof(Exception))]
public bool IsSuccessful => Exception is null;

/// <summary>
/// Check if the operation was cancelled.
/// </summary>
public bool IsCancelled => Exception is OperationCanceledException;

/// <summary>
/// Check if the operation was successful.
Expand All @@ -24,7 +30,7 @@ public void EnsureSuccess()
{
if (!IsSuccessful)
{
throw Exception;
ExceptionDispatchInfo.Throw(Exception);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,65 +8,64 @@ namespace CommunityToolkit.Maui.Storage;
/// <inheritdoc cref="IFolderPicker" />
[SupportedOSPlatform("iOS14.0")]
[SupportedOSPlatform("MacCatalyst14.0")]
public sealed partial class FolderPickerImplementation : IFolderPicker, IDisposable
public sealed partial class FolderPickerImplementation : IFolderPicker
{
readonly UIDocumentPickerViewController documentPickerViewController = new([UTTypes.Folder])
{
AllowsMultipleSelection = false
};

TaskCompletionSource<Folder>? taskCompetedSource;

/// <summary>
/// Initializes a new instance of the <see cref="FolderPickerImplementation"/> class.
/// </summary>
public FolderPickerImplementation()
{
documentPickerViewController.DidPickDocumentAtUrls += DocumentPickerViewControllerOnDidPickDocumentAtUrls;
documentPickerViewController.WasCancelled += DocumentPickerViewControllerOnWasCancelled;
}

/// <inheritdoc />
public void Dispose()
Task<Folder> InternalPickAsync(CancellationToken cancellationToken)
{
documentPickerViewController.DidPickDocumentAtUrls -= DocumentPickerViewControllerOnDidPickDocumentAtUrls;
documentPickerViewController.WasCancelled -= DocumentPickerViewControllerOnWasCancelled;
documentPickerViewController.Dispose();
return InternalPickAsync("/", cancellationToken);
}

async Task<Folder> InternalPickAsync(string initialPath, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
documentPickerViewController.DirectoryUrl = NSUrl.FromString(initialPath);
var currentViewController = Platform.GetCurrentUIViewController();

taskCompetedSource?.TrySetCanceled(CancellationToken.None);
var tcs = taskCompetedSource = new();
if (currentViewController is not null)
var currentViewController = Platform.GetCurrentUIViewController()
?? throw new FolderPickerException("Unable to get a window where to present the folder picker UI.");

var tcs = new TaskCompletionSource<Folder>(
TaskCreationOptions.RunContinuationsAsynchronously);

await using var registration = cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken));

using var picker = new UIDocumentPickerViewController([UTTypes.Folder]);
picker.AllowsMultipleSelection = false;
picker.DirectoryUrl = NSUrl.FromString(initialPath);

picker.DidPickDocumentAtUrls += OnPicked;
picker.WasCancelled += OnCancelled;

try
{
currentViewController.PresentViewController(documentPickerViewController, true, null);
currentViewController.PresentViewController(picker, true, null);
return await tcs.Task.WaitAsync(cancellationToken);
}
else
finally
{
throw new FolderPickerException("Unable to get a window where to present the folder picker UI.");
picker.DidPickDocumentAtUrls -= OnPicked;
picker.WasCancelled -= OnCancelled;
}

void OnPicked(object? sender, UIDocumentPickedAtUrlsEventArgs e)
{
if (e.Urls.Length is 0)
{
tcs.TrySetException(new FolderPickerException("No folder was selected."));
return;
}

return await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
}

Task<Folder> InternalPickAsync(CancellationToken cancellationToken)
{
return InternalPickAsync("/", cancellationToken);
}
var path = e.Urls[0].Path;
if (path is null)
{
tcs.TrySetException(new FolderPickerException("File path cannot be null."));
return;
}

void DocumentPickerViewControllerOnWasCancelled(object? sender, EventArgs e)
{
taskCompetedSource?.TrySetException(new FolderPickerException("Operation cancelled."));
}
tcs.TrySetResult(new Folder(path, new DirectoryInfo(path).Name));
}

void DocumentPickerViewControllerOnDidPickDocumentAtUrls(object? sender, UIDocumentPickedAtUrlsEventArgs e)
{
var path = e.Urls[0].Path ?? throw new FolderPickerException("Path cannot be null.");
taskCompetedSource?.TrySetResult(new Folder(path, new DirectoryInfo(path).Name));
void OnCancelled(object? sender, EventArgs e)
{
tcs.TrySetCanceled(cancellationToken);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.ExceptionServices;
using CommunityToolkit.Maui.Core.Primitives;

namespace CommunityToolkit.Maui.Storage;
Expand All @@ -17,6 +18,11 @@ public record FolderPickerResult(Folder? Folder, Exception? Exception)
[MemberNotNullWhen(false, nameof(Exception))]
public bool IsSuccessful => Exception is null;

/// <summary>
/// Check if the operation was cancelled.
/// </summary>
public bool IsCancelled => Exception is OperationCanceledException;

/// <summary>
/// Check if operation was successful.
/// </summary>
Expand All @@ -25,7 +31,7 @@ public void EnsureSuccess()
{
if (!IsSuccessful)
{
throw Exception;
ExceptionDispatchInfo.Throw(Exception);
}
}
}
Loading