Skip to content

Commit cab2374

Browse files
matt-goldmanVladislavAntonyukTheCodeTravelerCopilot
authored
Use Copy instead of Move in FileSaver UI on iOS (#2981)
* Test file saver with export to service mode * Used correct constructor with asCopy set to true * Add temporary directory cleanup in file saving process * Fixes disposal of temporary file on iOS Ensures temporary directory used by the file picker is always removed after the file selection is made, regardless of success or failure. Also improves error handling when a view controller cannot be retrieved. Fixes #2460 * Fixes disposal of temporary directory on iOS Ensures temporary directory used by the file picker is correctly removed on iOS. Moves the directory removal to the completion handler of the view controller presentation, ensuring it's executed after the picker is dismissed. Handles exceptions during file removal by re-throwing them after attempting to remove the temporary directory. * Update src/CommunityToolkit.Maui.Core/Essentials/FileSaver/FileSaverImplementation.macios.cs Co-authored-by: Copilot <[email protected]> * Refactor temp file cleanup in FileSaver (macOS/iOS) Move temp file removal to finally block to ensure cleanup always occurs. Remove redundant cleanup from view controller presentation callback. * Apply suggestion from @Copilot Co-authored-by: Copilot <[email protected]> * Ensure resources are only created if UI is available, improve error message, ensure temp directory is deleted (not just temp file) * Move dispose to finally * fix naming * fix order * check for index * rework File/FolderPicker. Removed IDisposable, Added IsCancelled * Remove `ConfigureAwait(false)`, Implement CancellationToken, Implement `.WaitAsync()` * Remove invalid `MemberNotNull`s * Use `ExceptionDispatchInfo.Throw` to improve exception handling * Remove `ConfigureAwait(false)`, Use `WaitAsync()` * Remove invalid `MemberNotNull` attributes, Use `ExceptionDispatchInfo.Throw` * Use `FolderPickerException` --------- Co-authored-by: Vladislav Antonyuk <[email protected]> Co-authored-by: Brandon Minnick <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 6b1de8d commit cab2374

File tree

6 files changed

+121
-108
lines changed

6 files changed

+121
-108
lines changed

samples/CommunityToolkit.Maui.Sample/ViewModels/Essentials/FileSaverViewModel.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,6 @@ async Task SaveFileInstance(CancellationToken cancellationToken)
8888
fileSaverResult.EnsureSuccess();
8989

9090
await Toast.Make($"File is saved: {fileSaverResult.FilePath}").Show(cancellationToken);
91-
#if IOS || MACCATALYST
92-
fileSaverInstance.Dispose();
93-
#endif
9491
}
9592
catch (Exception ex)
9693
{

samples/CommunityToolkit.Maui.Sample/ViewModels/Essentials/FolderPickerViewModel.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,6 @@ async Task PickFolderInstance(CancellationToken cancellationToken)
7979
folderPickerResult.EnsureSuccess();
8080

8181
await Toast.Make($"Folder picked: Name - {folderPickerResult.Folder.Name}, Path - {folderPickerResult.Folder.Path}", ToastDuration.Long).Show(cancellationToken);
82-
#if IOS || MACCATALYST
83-
folderPickerInstance.Dispose();
84-
#endif
8582
}
8683
catch (Exception e)
8784
{

src/CommunityToolkit.Maui.Core/Essentials/FileSaver/FileSaverImplementation.macios.cs

Lines changed: 62 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -6,85 +6,93 @@ namespace CommunityToolkit.Maui.Storage;
66
/// <inheritdoc cref="IFileSaver" />
77
[SupportedOSPlatform("iOS14.0")]
88
[SupportedOSPlatform("MacCatalyst14.0")]
9-
public sealed partial class FileSaverImplementation : IFileSaver, IDisposable
9+
public sealed partial class FileSaverImplementation : IFileSaver
1010
{
11-
UIDocumentPickerViewController? documentPickerViewController;
12-
TaskCompletionSource<string>? taskCompetedSource;
13-
14-
/// <inheritdoc />
15-
public void Dispose()
11+
Task<string> InternalSaveAsync(string fileName, Stream stream, IProgress<double>? progress, CancellationToken cancellationToken)
1612
{
17-
InternalDispose();
13+
return InternalSaveAsync("/", fileName, stream, progress, cancellationToken);
1814
}
19-
20-
async Task<string> InternalSaveAsync(string initialPath, string fileName, Stream stream, IProgress<double>? progress, CancellationToken cancellationToken)
15+
16+
async Task<string> InternalSaveAsync(
17+
string initialPath,
18+
string fileName,
19+
Stream stream,
20+
IProgress<double>? progress,
21+
CancellationToken cancellationToken)
2122
{
2223
cancellationToken.ThrowIfCancellationRequested();
24+
25+
var currentViewController = Platform.GetCurrentUIViewController()
26+
?? throw new FileSaveException(
27+
"Cannot present file picker: No active view controller found. Ensure the app is active with a visible window.");
28+
2329
var fileManager = NSFileManager.DefaultManager;
24-
var tempDirectoryPath = fileManager.GetTemporaryDirectory().Append(Guid.NewGuid().ToString(), true);
25-
var isDirectoryCreated = fileManager.CreateDirectory(tempDirectoryPath, true, null, out var error);
26-
if (!isDirectoryCreated)
30+
31+
var tempDirectoryPath = fileManager
32+
.GetTemporaryDirectory()
33+
.Append(Guid.NewGuid().ToString(), true);
34+
35+
if (!fileManager.CreateDirectory(tempDirectoryPath, true, null, out var error))
2736
{
2837
throw new FileSaveException(error?.LocalizedDescription ?? "Unable to create temp directory.");
2938
}
3039

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

34-
cancellationToken.ThrowIfCancellationRequested();
35-
taskCompetedSource?.TrySetCanceled(CancellationToken.None);
36-
var tcs = taskCompetedSource = new(cancellationToken);
42+
await WriteStream(
43+
stream,
44+
fileUrl.Path ?? throw new FileSaveException("Path cannot be null."),
45+
progress,
46+
cancellationToken);
3747

38-
documentPickerViewController = new([fileUrl])
39-
{
40-
DirectoryUrl = NSUrl.FromString(initialPath)
41-
};
42-
documentPickerViewController.DidPickDocumentAtUrls += DocumentPickerViewControllerOnDidPickDocumentAtUrls;
43-
documentPickerViewController.WasCancelled += DocumentPickerViewControllerOnWasCancelled;
48+
var tcs = new TaskCompletionSource<string>(
49+
TaskCreationOptions.RunContinuationsAsynchronously);
4450

45-
var currentViewController = Platform.GetCurrentUIViewController();
46-
if (currentViewController is not null)
47-
{
48-
currentViewController.PresentViewController(documentPickerViewController, true, null);
49-
}
50-
else
51-
{
52-
throw new FileSaveException("Unable to get a window where to present the file saver UI.");
53-
}
51+
await using var registration = cancellationToken.Register(() =>
52+
tcs.TrySetCanceled(cancellationToken));
5453

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

58-
Task<string> InternalSaveAsync(string fileName, Stream stream, IProgress<double>? progress, CancellationToken cancellationToken)
59-
{
60-
return InternalSaveAsync("/", fileName, stream, progress, cancellationToken);
61-
}
62-
63-
void DocumentPickerViewControllerOnWasCancelled(object? sender, EventArgs e)
64-
{
65-
taskCompetedSource?.TrySetException(new FileSaveException("Operation cancelled."));
66-
InternalDispose();
67-
}
57+
picker.DidPickDocumentAtUrls += OnPicked;
58+
picker.WasCancelled += OnCancelled;
6859

69-
void DocumentPickerViewControllerOnDidPickDocumentAtUrls(object? sender, UIDocumentPickedAtUrlsEventArgs e)
70-
{
7160
try
7261
{
73-
taskCompetedSource?.TrySetResult(e.Urls[0].Path ?? throw new FileSaveException("Unable to retrieve the path of the saved file."));
62+
cancellationToken.ThrowIfCancellationRequested();
63+
currentViewController.PresentViewController(picker, true, null);
64+
65+
return await tcs.Task.WaitAsync(cancellationToken);
7466
}
7567
finally
7668
{
77-
InternalDispose();
69+
fileManager.Remove(tempDirectoryPath, out _);
70+
71+
picker.DidPickDocumentAtUrls -= OnPicked;
72+
picker.WasCancelled -= OnCancelled;
73+
}
74+
75+
void OnPicked(object? sender, UIDocumentPickedAtUrlsEventArgs e)
76+
{
77+
if (e.Urls.Length is 0)
78+
{
79+
tcs.TrySetException(new FileSaveException("No file was selected."));
80+
return;
81+
}
82+
83+
var path = e.Urls[0].Path;
84+
if (path is null)
85+
{
86+
tcs.TrySetException(new FileSaveException("File path cannot be null."));
87+
return;
88+
}
89+
90+
tcs.TrySetResult(path);
7891
}
79-
}
8092

81-
void InternalDispose()
82-
{
83-
if (documentPickerViewController is not null)
93+
void OnCancelled(object? sender, EventArgs e)
8494
{
85-
documentPickerViewController.DidPickDocumentAtUrls -= DocumentPickerViewControllerOnDidPickDocumentAtUrls;
86-
documentPickerViewController.WasCancelled -= DocumentPickerViewControllerOnWasCancelled;
87-
documentPickerViewController.Dispose();
95+
tcs.TrySetCanceled(cancellationToken);
8896
}
8997
}
9098
}

src/CommunityToolkit.Maui.Core/Essentials/FileSaver/FileSaverResult.shared.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Diagnostics.CodeAnalysis;
2+
using System.Runtime.ExceptionServices;
23

34
namespace CommunityToolkit.Maui.Storage;
45

@@ -15,6 +16,11 @@ public record FileSaverResult(string? FilePath, Exception? Exception)
1516
[MemberNotNullWhen(true, nameof(FilePath))]
1617
[MemberNotNullWhen(false, nameof(Exception))]
1718
public bool IsSuccessful => Exception is null;
19+
20+
/// <summary>
21+
/// Check if the operation was cancelled.
22+
/// </summary>
23+
public bool IsCancelled => Exception is OperationCanceledException;
1824

1925
/// <summary>
2026
/// Check if the operation was successful.
@@ -24,7 +30,7 @@ public void EnsureSuccess()
2430
{
2531
if (!IsSuccessful)
2632
{
27-
throw Exception;
33+
ExceptionDispatchInfo.Throw(Exception);
2834
}
2935
}
3036
}

src/CommunityToolkit.Maui.Core/Essentials/FolderPicker/FolderPickerImplementation.macios.cs

Lines changed: 45 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -8,65 +8,64 @@ namespace CommunityToolkit.Maui.Storage;
88
/// <inheritdoc cref="IFolderPicker" />
99
[SupportedOSPlatform("iOS14.0")]
1010
[SupportedOSPlatform("MacCatalyst14.0")]
11-
public sealed partial class FolderPickerImplementation : IFolderPicker, IDisposable
11+
public sealed partial class FolderPickerImplementation : IFolderPicker
1212
{
13-
readonly UIDocumentPickerViewController documentPickerViewController = new([UTTypes.Folder])
14-
{
15-
AllowsMultipleSelection = false
16-
};
17-
18-
TaskCompletionSource<Folder>? taskCompetedSource;
19-
20-
/// <summary>
21-
/// Initializes a new instance of the <see cref="FolderPickerImplementation"/> class.
22-
/// </summary>
23-
public FolderPickerImplementation()
24-
{
25-
documentPickerViewController.DidPickDocumentAtUrls += DocumentPickerViewControllerOnDidPickDocumentAtUrls;
26-
documentPickerViewController.WasCancelled += DocumentPickerViewControllerOnWasCancelled;
27-
}
28-
29-
/// <inheritdoc />
30-
public void Dispose()
13+
Task<Folder> InternalPickAsync(CancellationToken cancellationToken)
3114
{
32-
documentPickerViewController.DidPickDocumentAtUrls -= DocumentPickerViewControllerOnDidPickDocumentAtUrls;
33-
documentPickerViewController.WasCancelled -= DocumentPickerViewControllerOnWasCancelled;
34-
documentPickerViewController.Dispose();
15+
return InternalPickAsync("/", cancellationToken);
3516
}
36-
17+
3718
async Task<Folder> InternalPickAsync(string initialPath, CancellationToken cancellationToken)
3819
{
3920
cancellationToken.ThrowIfCancellationRequested();
40-
documentPickerViewController.DirectoryUrl = NSUrl.FromString(initialPath);
41-
var currentViewController = Platform.GetCurrentUIViewController();
4221

43-
taskCompetedSource?.TrySetCanceled(CancellationToken.None);
44-
var tcs = taskCompetedSource = new();
45-
if (currentViewController is not null)
22+
var currentViewController = Platform.GetCurrentUIViewController()
23+
?? throw new FolderPickerException("Unable to get a window where to present the folder picker UI.");
24+
25+
var tcs = new TaskCompletionSource<Folder>(
26+
TaskCreationOptions.RunContinuationsAsynchronously);
27+
28+
await using var registration = cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken));
29+
30+
using var picker = new UIDocumentPickerViewController([UTTypes.Folder]);
31+
picker.AllowsMultipleSelection = false;
32+
picker.DirectoryUrl = NSUrl.FromString(initialPath);
33+
34+
picker.DidPickDocumentAtUrls += OnPicked;
35+
picker.WasCancelled += OnCancelled;
36+
37+
try
4638
{
47-
currentViewController.PresentViewController(documentPickerViewController, true, null);
39+
currentViewController.PresentViewController(picker, true, null);
40+
return await tcs.Task.WaitAsync(cancellationToken);
4841
}
49-
else
42+
finally
5043
{
51-
throw new FolderPickerException("Unable to get a window where to present the folder picker UI.");
44+
picker.DidPickDocumentAtUrls -= OnPicked;
45+
picker.WasCancelled -= OnCancelled;
5246
}
47+
48+
void OnPicked(object? sender, UIDocumentPickedAtUrlsEventArgs e)
49+
{
50+
if (e.Urls.Length is 0)
51+
{
52+
tcs.TrySetException(new FolderPickerException("No folder was selected."));
53+
return;
54+
}
5355

54-
return await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
55-
}
56-
57-
Task<Folder> InternalPickAsync(CancellationToken cancellationToken)
58-
{
59-
return InternalPickAsync("/", cancellationToken);
60-
}
56+
var path = e.Urls[0].Path;
57+
if (path is null)
58+
{
59+
tcs.TrySetException(new FolderPickerException("File path cannot be null."));
60+
return;
61+
}
6162

62-
void DocumentPickerViewControllerOnWasCancelled(object? sender, EventArgs e)
63-
{
64-
taskCompetedSource?.TrySetException(new FolderPickerException("Operation cancelled."));
65-
}
63+
tcs.TrySetResult(new Folder(path, new DirectoryInfo(path).Name));
64+
}
6665

67-
void DocumentPickerViewControllerOnDidPickDocumentAtUrls(object? sender, UIDocumentPickedAtUrlsEventArgs e)
68-
{
69-
var path = e.Urls[0].Path ?? throw new FolderPickerException("Path cannot be null.");
70-
taskCompetedSource?.TrySetResult(new Folder(path, new DirectoryInfo(path).Name));
66+
void OnCancelled(object? sender, EventArgs e)
67+
{
68+
tcs.TrySetCanceled(cancellationToken);
69+
}
7170
}
7271
}

src/CommunityToolkit.Maui.Core/Essentials/FolderPicker/FolderPickerResult.shared.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Diagnostics.CodeAnalysis;
2+
using System.Runtime.ExceptionServices;
23
using CommunityToolkit.Maui.Core.Primitives;
34

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

21+
/// <summary>
22+
/// Check if the operation was cancelled.
23+
/// </summary>
24+
public bool IsCancelled => Exception is OperationCanceledException;
25+
2026
/// <summary>
2127
/// Check if operation was successful.
2228
/// </summary>
@@ -25,7 +31,7 @@ public void EnsureSuccess()
2531
{
2632
if (!IsSuccessful)
2733
{
28-
throw Exception;
34+
ExceptionDispatchInfo.Throw(Exception);
2935
}
3036
}
3137
}

0 commit comments

Comments
 (0)