Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add FileSaverResult and FolderPickerResult, Fix Initial folder on Android #1009

Merged
merged 19 commits into from
Feb 27, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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 @@ -21,7 +21,7 @@


<Button Command="{Binding SaveFileStaticCommand}"
Text="Save using static FileSaver"
Text="Save safe using static FileSaver"
HorizontalOptions="Center"/>


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@


<Button Command="{Binding PickFolderStaticCommand}"
Text="Pick Folder using static FolderPicker"
Text="Pick Folder safe using static FolderPicker"
HorizontalOptions="Center"/>


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@ async Task SaveFile(CancellationToken cancellationToken)
async Task SaveFileStatic(CancellationToken cancellationToken)
{
using var stream = new MemoryStream(Encoding.Default.GetBytes("Hello from the Community Toolkit!"));
try
var fileSaveResult = await FileSaver.SaveSafeAsync("DCIM", "test.txt", stream, cancellationToken);
if (fileSaveResult.IsSuccessful)
{
var fileLocation = await FileSaver.SaveAsync("test.txt", stream, cancellationToken);
await Toast.Make($"File is saved: {fileLocation}").Show(cancellationToken);
await Toast.Make($"File is saved: {fileSaveResult.FilePath}").Show(cancellationToken);
}
catch (Exception ex)
else
{
await Toast.Make($"File is not saved, {ex.Message}").Show(cancellationToken);
await Toast.Make($"File is not saved, {fileSaveResult.Exception.Message}").Show(cancellationToken);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,16 @@ async Task PickFolder(CancellationToken cancellationToken)
[RelayCommand]
async Task PickFolderStatic(CancellationToken cancellationToken)
{
try
{
var folder = await FolderPicker.PickAsync(cancellationToken);
await Toast.Make($"Folder picked: Name - {folder.Name}, Path - {folder.Path}", ToastDuration.Long).Show(cancellationToken);
}
catch (Exception ex)
{
await Toast.Make($"Folder is not picked, {ex.Message}").Show(cancellationToken);
}
var folderResult = await FolderPicker.PickSafeAsync("DCIM", cancellationToken);
if (folderResult.IsSuccessful)
{
await Toast.Make($"Folder picked: Name - {folderResult.Folder.Name}, Path - {folderResult.Folder.Path}", ToastDuration.Long).Show(cancellationToken);

}
else
{
await Toast.Make($"Folder is not picked, {folderResult.Exception.Message}").Show(cancellationToken);
}
}

[RelayCommand]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ public static Task<string> SaveAsync(string initialPath, string fileName, Stream
public static Task<string> SaveAsync(string fileName, Stream stream, CancellationToken cancellationToken) =>
Default.SaveAsync(fileName, stream, cancellationToken);

/// <inheritdoc cref="IFileSaver.SaveSafeAsync(string, string, Stream, CancellationToken)"/>
public static Task<FileSaverResult> SaveSafeAsync(string initialPath, string fileName, Stream stream, CancellationToken cancellationToken) =>
Default.SaveSafeAsync(initialPath, fileName, stream, cancellationToken);

/// <inheritdoc cref="IFileSaver.SaveSafeAsync(string, Stream, CancellationToken)"/>
public static Task<FileSaverResult> SaveSafeAsync(string fileName, Stream stream, CancellationToken cancellationToken) =>
Default.SaveSafeAsync(fileName, stream, cancellationToken);

internal static void SetDefault(IFileSaver implementation) =>
defaultImplementation = new(implementation);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using System.Web;
using Android.Content;
using Android.OS.Storage;
using Android.Provider;
using Android.Webkit;
using Java.IO;
using Microsoft.Maui.ApplicationModel;
Expand All @@ -19,10 +22,19 @@ public async Task<string> SaveAsync(string initialPath, string fileName, Stream
throw new PermissionException("Storage permission is not granted.");
}

const string baseUrl = "content://com.android.externalstorage.documents/document/primary%3A";
if (Android.OS.Environment.ExternalStorageDirectory is not null)
{
initialPath = initialPath.Replace(Android.OS.Environment.ExternalStorageDirectory.ToString(), string.Empty, StringComparison.InvariantCulture);
VladislavAntonyuk marked this conversation as resolved.
Show resolved Hide resolved
}

var initialFolderUri = Android.Net.Uri.Parse(baseUrl + HttpUtility.UrlEncode(initialPath));
var intent = new Intent(Intent.ActionCreateDocument);

intent.AddCategory(Intent.CategoryOpenable);
intent.SetType(MimeTypeMap.Singleton?.GetMimeTypeFromExtension(GetExtension(fileName)) ?? "*/*");
intent.SetType(MimeTypeMap.Singleton?.GetMimeTypeFromExtension(MimeTypeMap.GetFileExtensionFromUrl(fileName)) ?? "*/*");
intent.PutExtra(Intent.ExtraTitle, fileName);
intent.PutExtra(DocumentsContract.ExtraInitialUri, initialFolderUri);
var pickerIntent = Intent.CreateChooser(intent, string.Empty) ?? throw new InvalidOperationException("Unable to create intent.");

AndroidUri? filePath = null;
Expand Down Expand Up @@ -81,8 +93,8 @@ static async Task<string> SaveDocument(AndroidUri uri, Stream stream, Cancellati

fileOutputStream.Close();
parcelFileDescriptor?.Close();
var split = uri.Path?.Split(":") ?? throw new FolderPickerException("Unable to resolve path.");
var split = uri.Path?.Split(':') ?? throw new FolderPickerException("Unable to resolve path.");

return Android.OS.Environment.ExternalStorageDirectory + "/" + split[1];
return $"{Android.OS.Environment.ExternalStorageDirectory}/{split[^1]}";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public async Task<string> SaveAsync(string initialPath, string fileName, Stream
taskCompetedSource = new TaskCompletionSource<string>();

documentPickerViewController = new UIDocumentPickerViewController(new[] { fileUrl });
documentPickerViewController.DirectoryUrl = NSUrl.FromString(initialPath);
documentPickerViewController.DidPickDocumentAtUrls += DocumentPickerViewControllerOnDidPickDocumentAtUrls;
documentPickerViewController.WasCancelled += DocumentPickerViewControllerOnWasCancelled;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Diagnostics.CodeAnalysis;

namespace CommunityToolkit.Maui.Storage;

/// <summary>
/// Result of the <see cref="IFileSaver"/>
/// </summary>
/// <param name="FilePath">Saved file path</param>
/// <param name="Exception">Exception if operation failed</param>
public record FileSaverResult(string? FilePath, Exception? Exception)
VladislavAntonyuk marked this conversation as resolved.
Show resolved Hide resolved
{
/// <summary>
/// Check if operation was successful.
/// </summary>
[MemberNotNullWhen(true, nameof(FilePath))]
[MemberNotNullWhen(false, nameof(Exception))]
public bool IsSuccessful => Exception is null;

/// <summary>
/// Check if operation was successful.
/// </summary>
[MemberNotNull(nameof(FilePath))]
public void EnsureSuccess()
VladislavAntonyuk marked this conversation as resolved.
Show resolved Hide resolved
{
if (!IsSuccessful)
{
throw Exception;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,47 @@ public interface IFileSaver
/// <param name="stream"><see cref="Stream"/></param>
/// <param name="cancellationToken"><see cref="CancellationToken"/></param>
Task<string> SaveAsync(string fileName, Stream stream, CancellationToken cancellationToken);

/// <summary>
/// Saves a file to a target folder on the file system.
/// This method doesn't throw Exception.
/// </summary>
/// <param name="initialPath">Initial path</param>
/// <param name="fileName">File name with extension</param>
/// <param name="stream"><see cref="Stream"/></param>
/// <param name="cancellationToken"><see cref="CancellationToken"/></param>
async Task<FileSaverResult> SaveSafeAsync(string initialPath, string fileName, Stream stream, CancellationToken cancellationToken)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
var path = await SaveAsync(initialPath, fileName, stream, cancellationToken);
VladislavAntonyuk marked this conversation as resolved.
Show resolved Hide resolved
return new FileSaverResult(path, null);
}
catch (Exception e)
{
return new FileSaverResult(null, e);
}
}

/// <summary>
/// Saves a file to the default folder on the file system.
/// This method doesn't throw Exception.
/// </summary>
/// <param name="fileName">File name with extension</param>
/// <param name="stream"><see cref="Stream"/></param>
/// <param name="cancellationToken"><see cref="CancellationToken"/></param>
async Task<FileSaverResult> SaveSafeAsync(string fileName, Stream stream, CancellationToken cancellationToken)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
var path = await SaveAsync(fileName, stream, cancellationToken);
VladislavAntonyuk marked this conversation as resolved.
Show resolved Hide resolved
return new FileSaverResult(path, null);
}
catch (Exception e)
{
return new FileSaverResult(null, e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ public static Task<Folder> PickAsync(string initialPath, CancellationToken cance
public static Task<Folder> PickAsync(CancellationToken cancellationToken) =>
Default.PickAsync(cancellationToken);

/// <inheritdoc cref="IFolderPicker.PickSafeAsync(string, CancellationToken)"/>
public static Task<FolderPickerResult> PickSafeAsync(string initialPath, CancellationToken cancellationToken) =>
Default.PickSafeAsync(initialPath, cancellationToken);

/// <inheritdoc cref="IFolderPicker.PickSafeAsync(CancellationToken)"/>
public static Task<FolderPickerResult> PickSafeAsync(CancellationToken cancellationToken) =>
Default.PickSafeAsync(cancellationToken);

internal static void SetDefault(IFolderPicker implementation) =>
defaultImplementation = new(implementation);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Web;
using Android.Content;
using Android.Provider;
using CommunityToolkit.Maui.Core.Primitives;
using Microsoft.Maui.ApplicationModel;
using AndroidUri = Android.Net.Uri;
Expand All @@ -18,8 +20,16 @@ public async Task<Folder> PickAsync(string initialPath, CancellationToken cancel
}

Folder? folder = null;
const string baseUrl = "content://com.android.externalstorage.documents/document/primary%3A";
if (Android.OS.Environment.ExternalStorageDirectory is not null)
{
initialPath = initialPath.Replace(Android.OS.Environment.ExternalStorageDirectory.ToString(), string.Empty, StringComparison.InvariantCulture);
VladislavAntonyuk marked this conversation as resolved.
Show resolved Hide resolved
}

var initialFolderUri = Android.Net.Uri.Parse(baseUrl + HttpUtility.UrlEncode(initialPath));

var intent = new Intent(Intent.ActionOpenDocumentTree);
intent.PutExtra(DocumentsContract.ExtraInitialUri, initialFolderUri);
var pickerIntent = Intent.CreateChooser(intent, string.Empty) ?? throw new InvalidOperationException("Unable to create intent.");

await IntermediateActivity.StartAsync(pickerIntent, (int)AndroidRequestCode.RequestCodeFolderPicker, onResult: OnResult).WaitAsync(cancellationToken);
Expand Down Expand Up @@ -54,8 +64,8 @@ static string EnsurePhysicalPath(AndroidUri? uri)
const string uriSchemeFolder = "content";
if (uri.Scheme is not null && uri.Scheme.Equals(uriSchemeFolder, StringComparison.OrdinalIgnoreCase))
{
var split = uri.Path?.Split(":") ?? throw new FolderPickerException("Unable to resolve path.");
return Android.OS.Environment.ExternalStorageDirectory + "/" + split[1];
var split = uri.Path?.Split(':') ?? throw new FolderPickerException("Unable to resolve path.");
return $"{Android.OS.Environment.ExternalStorageDirectory}/{split[^1]}";
}

throw new FolderPickerException($"Unable to resolve absolute path or retrieve contents of URI '{uri}'.");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Diagnostics.CodeAnalysis;
using CommunityToolkit.Maui.Core.Primitives;

namespace CommunityToolkit.Maui.Storage;

/// <summary>
/// Result of the <see cref="IFolderPicker"/>
/// </summary>
/// <param name="Folder"><see cref="Folder"/></param>
/// <param name="Exception">Exception if operation failed</param>
public record FolderPickerResult(Folder? Folder, Exception? Exception)
{
/// <summary>
/// Check if operation was successful.
/// </summary>
[MemberNotNullWhen(true, nameof(Folder))]
[MemberNotNullWhen(false, nameof(Exception))]
public bool IsSuccessful => Exception is null;

/// <summary>
/// Check if operation was successful.
/// </summary>
[MemberNotNull(nameof(Folder))]
public void EnsureSuccess()
VladislavAntonyuk marked this conversation as resolved.
Show resolved Hide resolved
{
if (!IsSuccessful)
{
throw Exception;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,45 @@ public interface IFolderPicker
/// <param name="cancellationToken"><see cref="CancellationToken"/></param>
/// <returns><see cref="Folder"/></returns>
Task<Folder> PickAsync(CancellationToken cancellationToken);

/// <summary>
/// Allows the user to pick a folder from the file system.
/// This method doesn't throw Exception.
/// </summary>
/// <param name="initialPath">Initial path</param>
/// <param name="cancellationToken"><see cref="CancellationToken"/></param>
/// <returns><see cref="FolderPickerResult"/></returns>
async Task<FolderPickerResult> PickSafeAsync(string initialPath, CancellationToken cancellationToken)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
var folder = await PickAsync(initialPath, cancellationToken);
VladislavAntonyuk marked this conversation as resolved.
Show resolved Hide resolved
return new FolderPickerResult(folder, null);
}
catch (Exception e)
{
return new FolderPickerResult(null, e);
}
}

/// <summary>
/// Allows the user to pick a folder from the file system.
/// This method doesn't throw Exception.
/// </summary>
/// <param name="cancellationToken"><see cref="CancellationToken"/></param>
/// <returns><see cref="FolderPickerResult"/></returns>
async Task<FolderPickerResult> PickSafeAsync(CancellationToken cancellationToken)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
var folder = await PickAsync(cancellationToken);
VladislavAntonyuk marked this conversation as resolved.
Show resolved Hide resolved
return new FolderPickerResult(folder, null);
}
catch (Exception e)
{
return new FolderPickerResult(null, e);
}
}
}
24 changes: 24 additions & 0 deletions src/CommunityToolkit.Maui.UnitTests/Essentials/FileSaverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,28 @@ public async Task SaveAsyncFailsOnNet()
await Assert.ThrowsAsync<NotImplementedException>(() => FileSaver.SaveAsync("file name", Stream.Null, CancellationToken.None));
await Assert.ThrowsAsync<NotImplementedException>(() => FileSaver.SaveAsync("initial path", "file name", Stream.Null, CancellationToken.None));
}

[Fact]
public async Task SaveSafeAsyncFailsOnNet()
{
FileSaver.SetDefault(new FileSaverImplementation());
var result = await FileSaver.SaveSafeAsync("fileName", Stream.Null, CancellationToken.None);
result.Should().NotBeNull();
result.Exception.Should().BeOfType<NotImplementedException>();
result.FilePath.Should().BeNull();
result.IsSuccessful.Should().BeFalse();
Assert.Throws<NotImplementedException>(result.EnsureSuccess);
}

[Fact]
public async Task SaveSafeAsyncWithInitialPathFailsOnNet()
{
FileSaver.SetDefault(new FileSaverImplementation());
var result = await FileSaver.SaveSafeAsync("initial path","fileName", Stream.Null, CancellationToken.None);
result.Should().NotBeNull();
result.Exception.Should().BeOfType<NotImplementedException>();
result.FilePath.Should().BeNull();
result.IsSuccessful.Should().BeFalse();
Assert.Throws<NotImplementedException>(result.EnsureSuccess);
}
}
Loading