6 changed files with 455 additions and 29 deletions
@ -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)); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue