diff --git a/src/Android/Avalonia.Android/AndroidPlatform.cs b/src/Android/Avalonia.Android/AndroidPlatform.cs index c7dd896cb8..0c72c389dc 100644 --- a/src/Android/Avalonia.Android/AndroidPlatform.cs +++ b/src/Android/Avalonia.Android/AndroidPlatform.cs @@ -1,10 +1,8 @@ using System; using Avalonia.Controls; -using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Android; using Avalonia.Android.Platform; using Avalonia.Android.Platform.Input; -using Avalonia.Controls.Platform; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.OpenGL.Egl; @@ -55,7 +53,6 @@ namespace Avalonia.Android .Bind().ToSingleton() .Bind().ToConstant(Instance) .Bind().ToConstant(new AndroidThreadingInterface()) - .Bind().ToTransient() .Bind().ToSingleton() .Bind().ToConstant(new ChoreographerTimer()) .Bind().ToConstant(new RenderLoop()) diff --git a/src/Android/Avalonia.Android/AvaloniaActivity.cs b/src/Android/Avalonia.Android/AvaloniaActivity.cs index f3692d33d7..4ee4bc1375 100644 --- a/src/Android/Avalonia.Android/AvaloniaActivity.cs +++ b/src/Android/Avalonia.Android/AvaloniaActivity.cs @@ -4,10 +4,14 @@ using Android.Content.Res; using AndroidX.Lifecycle; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls; +using Android.Runtime; +using Android.App; +using Android.Content; +using System; namespace Avalonia.Android { - public abstract class AvaloniaActivity : AppCompatActivity where TApp : Application, new() + public abstract class AvaloniaActivity : AppCompatActivity { internal class SingleViewLifetime : ISingleViewApplicationLifetime { @@ -20,16 +24,15 @@ namespace Avalonia.Android } } + internal Action ActivityResult; internal AvaloniaView View; internal AvaloniaViewModel _viewModel; - protected virtual AppBuilder CustomizeAppBuilder(AppBuilder builder) => builder.UseAndroid(); + protected abstract AppBuilder CreateAppBuilder(); protected override void OnCreate(Bundle savedInstanceState) { - var builder = AppBuilder.Configure(); - - CustomizeAppBuilder(builder); + var builder = CreateAppBuilder(); var lifetime = new SingleViewLifetime(); @@ -79,5 +82,24 @@ namespace Avalonia.Android 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 : AvaloniaActivity where TApp : Application, new() + { + protected virtual AppBuilder CustomizeAppBuilder(AppBuilder builder) => builder.UseAndroid(); + + protected override AppBuilder CreateAppBuilder() + { + var builder = AppBuilder.Configure(); + + return CustomizeAppBuilder(builder); + } } } diff --git a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs index 10f98609bf..fd97e293f9 100644 --- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs +++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs @@ -7,6 +7,7 @@ using Android.Views.InputMethods; using Avalonia.Android.OpenGL; using Avalonia.Android.Platform.Specific; using Avalonia.Android.Platform.Specific.Helpers; +using Avalonia.Android.Platform.Storage; using Avalonia.Controls; using Avalonia.Controls.Platform; using Avalonia.Controls.Platform.Surfaces; @@ -16,11 +17,13 @@ using Avalonia.Input.TextInput; using Avalonia.OpenGL.Egl; using Avalonia.OpenGL.Surfaces; using Avalonia.Platform; +using Avalonia.Platform.Storage; using Avalonia.Rendering; 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 IFramebufferPlatformSurface _framebuffer; @@ -46,6 +49,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform _view.Resources.DisplayMetrics.HeightPixels).ToSize(RenderScaling); NativeControlHost = new AndroidNativeControlHostImpl(avaloniaView); + StorageProvider = new AndroidStorageProvider((AvaloniaActivity)avaloniaView.Context); } public virtual Point GetAvaloniaPointFromEvent(MotionEvent e, int pointerIndex) => @@ -225,6 +229,8 @@ namespace Avalonia.Android.Platform.SkiaPlatform public ITextInputMethodImpl TextInputMethod => _textInputMethod; public INativeControlHostImpl NativeControlHost { get; } + + public IStorageProvider StorageProvider { get; } public void SetTransparencyLevelHint(WindowTransparencyLevel transparencyLevel) { diff --git a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs new file mode 100644 index 0000000000..50581d47b1 --- /dev/null +++ b/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 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 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 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(new AndroidStorageFolder(Context, androidUri)); + } + + return Task.FromResult(null); + } + + public void Dispose() + { + _context = null; + } +} + +internal sealed class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder +{ + public AndroidStorageFolder(Context context, AndroidUri uri) : base(context, uri) + { + } + + public override Task 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 OpenRead() => Task.FromResult(OpenContentStream(Context, Uri, false) + ?? throw new InvalidOperationException("Failed to open content stream")); + + public Task 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 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)); + } +} diff --git a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs new file mode 100644 index 0000000000..653f450ec8 --- /dev/null +++ b/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 OpenFolderBookmarkAsync(string bookmark) + { + var uri = AndroidUri.Parse(bookmark) ?? throw new ArgumentException("Couldn't parse Bookmark value", nameof(bookmark)); + return Task.FromResult(new AndroidStorageFolder(_activity, uri)); + } + + public Task OpenFileBookmarkAsync(string bookmark) + { + var uri = AndroidUri.Parse(bookmark) ?? throw new ArgumentException("Couldn't parse Bookmark value", nameof(bookmark)); + return Task.FromResult(new AndroidStorageFile(_activity, uri)); + } + + public async Task> OpenFilePickerAsync(FilePickerOpenOptions options) + { + var mimeTypes = options.FileTypeFilter?.Where(t => t != FilePickerFileTypes.All) + .SelectMany(f => f.MimeTypes ?? Array.Empty()).Distinct().ToArray() ?? Array.Empty(); + + 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 SaveFilePickerAsync(FilePickerSaveOptions options) + { + var mimeTypes = options.FileTypeChoices?.Where(t => t != FilePickerFileTypes.All) + .SelectMany(f => f.MimeTypes ?? Array.Empty()).Distinct().ToArray() ?? Array.Empty(); + + 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> 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> StartActivity(Intent? pickerIntent, bool singleResult) + { + var resultList = new List(1); + var tcs = new TaskCompletionSource(); + 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; + } +} diff --git a/src/Android/Avalonia.Android/SystemDialogImpl.cs b/src/Android/Avalonia.Android/SystemDialogImpl.cs deleted file mode 100644 index 1ed1f688b1..0000000000 --- a/src/Android/Avalonia.Android/SystemDialogImpl.cs +++ /dev/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 ShowFileDialogAsync(FileDialog dialog, Window parent) - { - throw new NotImplementedException(); - } - - public Task ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) - { - throw new NotImplementedException(); - } - } -}