From a508adbc6afe2cba399c799c435bdc822b75a99c Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 8 Jun 2022 02:39:22 -0400 Subject: [PATCH] Android implementation --- .../Avalonia.Android/AndroidPlatform.cs | 3 - .../Avalonia.Android/AvaloniaActivity.cs | 32 +- .../Platform/SkiaPlatform/TopLevelImpl.cs | 8 +- .../Platform/Storage/AndroidStorageFile.cs | 376 ++++++++++++++++++ .../Storage/AndroidStorageProvider.cs | 165 ++++++++ .../Avalonia.Android/SystemDialogImpl.cs | 20 - src/Avalonia.Base/Logging/LogArea.cs | 5 + 7 files changed, 580 insertions(+), 29 deletions(-) create mode 100644 src/Android/Avalonia.Android/Platform/Storage/AndroidStorageFile.cs create mode 100644 src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs delete mode 100644 src/Android/Avalonia.Android/SystemDialogImpl.cs diff --git a/src/Android/Avalonia.Android/AndroidPlatform.cs b/src/Android/Avalonia.Android/AndroidPlatform.cs index 61aa6ce9468..4c037ed3fae 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 @@ public static void Initialize(AndroidPlatformOptions options) .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 f5d620a97a8..19b032446a3 100644 --- a/src/Android/Avalonia.Android/AvaloniaActivity.cs +++ b/src/Android/Avalonia.Android/AvaloniaActivity.cs @@ -4,10 +4,14 @@ 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 @@ public Control MainView } } + 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(); View = new AvaloniaView(this); SetContentView(View); @@ -78,5 +81,24 @@ protected override void OnDestroy() 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 10f98609bf7..6cd3fb27bfd 100644 --- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs +++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs @@ -7,6 +7,7 @@ using Avalonia.Android.OpenGL; using Avalonia.Android.Platform.Specific; using Avalonia.Android.Platform.Specific.Helpers; +using Avalonia.Android.Storage; using Avalonia.Controls; using Avalonia.Controls.Platform; using Avalonia.Controls.Platform.Surfaces; @@ -16,11 +17,13 @@ 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 @@ public TopLevelImpl(AvaloniaView avaloniaView, bool placeOnTop = false) _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 @@ public sealed override IInputConnection OnCreateInputConnection(EditorInfo outAt 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/AndroidStorageFile.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageFile.cs new file mode 100644 index 00000000000..38826cf7b5c --- /dev/null +++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageFile.cs @@ -0,0 +1,376 @@ +#nullable enable + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Android.Content; +using Android.OS; +using Android.Provider; +using Avalonia.Logging; +using Avalonia.Platform.Storage; +using AndroidUri = Android.Net.Uri; + +namespace Avalonia.Android.Platform.Storage +{ + internal class AndroidStorageFile : IStorageBookmarkFile + { + private const string storageTypePrimary = "primary"; + private const string storageTypeRaw = "raw"; + private const string storageTypeImage = "image"; + private const string storageTypeVideo = "video"; + private const string storageTypeAudio = "audio"; + private static readonly string[] contentUriPrefixes = + { + "content://downloads/public_downloads", + "content://downloads/my_downloads", + "content://downloads/all_downloads", + }; + internal const string UriSchemeFile = "file"; + internal const string UriSchemeContent = "content"; + + internal const string UriAuthorityExternalStorage = "com.android.externalstorage.documents"; + internal const string UriAuthorityDownloads = "com.android.providers.downloads.documents"; + internal const string UriAuthorityMedia = "com.android.providers.media.documents"; + + private readonly Context _context; + private readonly AndroidUri _uri; + + public AndroidStorageFile(Context context, AndroidUri uri) + { + _context = context; + _uri = uri; + } + + public string Name => GetColumnValue(_context, _uri, MediaStore.Files.FileColumns.DisplayName) + ?? _uri.PathSegments?.LastOrDefault() ?? string.Empty; + + public bool CanBookmark => true; + + 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")); + + public Task SaveBookmark() + { + _context.ContentResolver?.TakePersistableUriPermission(_uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission); + return Task.FromResult(_uri.ToString()); + } + + public bool TryGetFullPath([NotNullWhen(true)] out string? path) + { + path = EnsurePhysicalPath(_context, _uri, true); + return path is not null; + } + + public Task GetBasicPropertiesAsync() + { + ulong? size = null; + DateTimeOffset? itemDate = null; + DateTimeOffset? dateModified = null; + + var projection = new[] + { + MediaStore.Files.FileColumns.Size, + MediaStore.Files.FileColumns.DateAdded, + MediaStore.Files.FileColumns.DateModified + }; + using var cursor = _context.ContentResolver!.Query(_uri, projection, null, null, null); + + if (cursor?.MoveToFirst() == true) + { + try + { + var columnIndex = cursor.GetColumnIndex(MediaStore.Files.FileColumns.Size); + if (columnIndex != -1) + { + size = (ulong)cursor.GetLong(columnIndex); + } + } + catch (System.Exception ex) + { + Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)? + .Log(this, "File Size metadata reader failed: '{Exception}'", ex); + } + try + { + var columnIndex = cursor.GetColumnIndex(MediaStore.Files.FileColumns.DateAdded); + if (columnIndex != -1) + { + itemDate = DateTimeOffset.FromUnixTimeMilliseconds(cursor.GetLong(columnIndex)); + } + } + catch (System.Exception ex) + { + Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)? + .Log(this, "File DateAdded metadata reader failed: '{Exception}'", ex); + } + try + { + var columnIndex = cursor.GetColumnIndex(MediaStore.Files.FileColumns.DateModified); + if (columnIndex != -1) + { + dateModified = DateTimeOffset.FromUnixTimeMilliseconds(cursor.GetLong(columnIndex)); + } + } + catch (System.Exception ex) + { + Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)? + .Log(this, "File DateAdded metadata reader failed: '{Exception}'", ex); + } + } + + return Task.FromResult(new StorageItemProperties(size, itemDate, dateModified)); + } + + internal string? EnsurePhysicalPath(Context context, AndroidUri uri, bool requireExtendedAccess = true) + { + // if this is a file, use that + if (uri.Scheme?.Equals(UriSchemeFile, StringComparison.OrdinalIgnoreCase) == true) + return uri.Path; + + // try resolve using the content provider + var absolute = ResolvePhysicalPath(context, uri, requireExtendedAccess); + if (!string.IsNullOrWhiteSpace(absolute) && Path.IsPathRooted(absolute)) + return absolute; + + return null; + } + + private string? ResolvePhysicalPath(Context context, AndroidUri uri, bool requireExtendedAccess = true) + { + if (uri.Scheme?.Equals(UriSchemeFile, StringComparison.OrdinalIgnoreCase) == true) + { + // if it is a file, then return directly + + var resolved = uri.Path; + if (File.Exists(resolved)) + return resolved; + } + else + { + // if this is on an older OS version, or we just need it now + + if (Build.VERSION.SdkInt >= BuildVersionCodes.Kitkat && DocumentsContract.IsDocumentUri(context, uri)) + { + var resolved = ResolveDocumentPath(context, uri); + if (File.Exists(resolved)) + return resolved; + } + else if (uri.Scheme?.Equals(UriSchemeContent, StringComparison.OrdinalIgnoreCase) == true) + { + var resolved = ResolveContentPath(context, uri); + if (File.Exists(resolved)) + return resolved; + } + } + + return null; + } + + private string? ResolveDocumentPath(Context context, AndroidUri uri) + { + Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "Trying to resolve document URI: '{Uri}'", uri); + + var docId = DocumentsContract.GetDocumentId(uri); + + var docIdParts = docId?.Split(':'); + if (docIdParts == null || docIdParts.Length == 0) + return null; + + if (uri.Authority?.Equals(UriAuthorityExternalStorage, StringComparison.OrdinalIgnoreCase) == true) + { + Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "Resolving external storage URI: '{Uri}'", uri); + + if (docIdParts.Length == 2) + { + var storageType = docIdParts[0]; + var uriPath = docIdParts[1]; + + // This is the internal "external" memory, NOT the SD Card + if (storageType.Equals(storageTypePrimary, StringComparison.OrdinalIgnoreCase)) + { +#pragma warning disable CS0618 // Type or member is obsolete + var root = global::Android.OS.Environment.ExternalStorageDirectory!.Path; +#pragma warning restore CS0618 // Type or member is obsolete + + return Path.Combine(root, uriPath); + } + + // TODO: support other types, such as actual SD Cards + } + } + else if (uri.Authority?.Equals(UriAuthorityDownloads, StringComparison.OrdinalIgnoreCase) == true) + { + Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "Resolving downloads URI: '{Uri}'", uri); + + // NOTE: This only really applies to older Android vesions since the privacy changes + + if (docIdParts.Length == 2) + { + var storageType = docIdParts[0]; + var uriPath = docIdParts[1]; + + if (storageType.Equals(storageTypeRaw, StringComparison.OrdinalIgnoreCase)) + return uriPath; + } + + // ID could be "###" or "msf:###" + var fileId = docIdParts.Length == 2 + ? docIdParts[1] + : docIdParts[0]; + + foreach (var prefix in contentUriPrefixes) + { + var uriString = prefix + "/" + fileId; + var contentUri = AndroidUri.Parse(uriString)!; + + if (GetDataFilePath(context, contentUri) is string filePath) + return filePath; + } + } + else if (uri.Authority?.Equals(UriAuthorityMedia, StringComparison.OrdinalIgnoreCase) == true) + { + Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "Resolving media URI: '{Uri}'", uri); + + if (docIdParts.Length == 2) + { + var storageType = docIdParts[0]; + var uriPath = docIdParts[1]; + + AndroidUri? contentUri = null; + if (storageType.Equals(storageTypeImage, StringComparison.OrdinalIgnoreCase)) + contentUri = MediaStore.Images.Media.ExternalContentUri; + else if (storageType.Equals(storageTypeVideo, StringComparison.OrdinalIgnoreCase)) + contentUri = MediaStore.Video.Media.ExternalContentUri; + else if (storageType.Equals(storageTypeAudio, StringComparison.OrdinalIgnoreCase)) + contentUri = MediaStore.Audio.Media.ExternalContentUri; + + if (contentUri != null && GetDataFilePath(context, contentUri, $"{MediaStore.MediaColumns.Id}=?", new[] { uriPath }) is string filePath) + return filePath; + } + } + + Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "Unable to resolve document URI: '{Uri}'", uri); + + return null; + } + + private string? ResolveContentPath(Context context, AndroidUri uri) + { + Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "Trying to resolve content URI: '{Uri}'", uri); + + if (GetDataFilePath(context, uri) is string filePath) + return filePath; + + // TODO: support some additional things, like Google Photos if that is possible + + Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "Unable to resolve content URI: '{Uri}'", uri); + + return null; + } + + 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; + } + + private string? GetColumnValue(Context context, AndroidUri contentUri, string column, string? selection = null, string[]? selectionArgs = null) + { + try + { + var value = QueryContentResolverColumn(context, contentUri, column, selection, selectionArgs); + if (!string.IsNullOrEmpty(value)) + return value; + } + catch (System.Exception ex) + { + Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "File metadata reader failed: '{Exception}'", ex); + } + + return null; + } + + private string? GetDataFilePath(Context context, AndroidUri contentUri, string? selection = null, string[]? selectionArgs = null) + { +#pragma warning disable CS0618 // Type or member is obsolete + const string column = MediaStore.Files.FileColumns.Data; +#pragma warning restore CS0618 // Type or member is obsolete + + // ask the content provider for the data column, which may contain the actual file path + var path = GetColumnValue(context, contentUri, column, selection, selectionArgs); + return !string.IsNullOrEmpty(path) && Path.IsPathRooted(path) ? path : null; + } + + private string? QueryContentResolverColumn(Context context, AndroidUri contentUri, string columnName, string? selection = null, string[]? selectionArgs = null) + { + string? text = null; + + var projection = new[] { columnName }; + using var cursor = context.ContentResolver!.Query(contentUri, projection, selection, selectionArgs, null); + if (cursor?.MoveToFirst() == true) + { + var columnIndex = cursor.GetColumnIndex(columnName); + if (columnIndex != -1) + text = cursor.GetString(columnIndex); + } + + return text; + } + + public Task GetParentAsync() + { + throw new NotImplementedException(); + } + } +} 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 00000000000..cfa2d5d2197 --- /dev/null +++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs @@ -0,0 +1,165 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using Android.App; +using Android.Content; +using Avalonia.Android.Platform.Storage; +using Avalonia.Logging; +using Avalonia.Platform.Storage; +using AndroidUri = Android.Net.Uri; + +namespace Avalonia.Android.Storage +{ + internal class AndroidStorageProvider : IStorageProvider + { + private readonly AvaloniaActivity _activity; + private int _lastRequestCode = 0; + + public AndroidStorageProvider(AvaloniaActivity activity) + { + _activity = activity; + } + + public bool CanOpen => true; + + public bool CanSave => true; + + public bool CanPickFolder => false; + + public Task OpenFolderBookmarkAsync(string bookmark) + { + throw new NotImplementedException(); + } + + public Task OpenFolderPickerAsync(FolderPickerOpenOptions options) + { + throw new NotImplementedException(); + } + + 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 resultList = new List(); + + try + { + var mimeTypes = options.FileTypeFilter?.Where(t => t != FilePickerFileTypes.All) + .SelectMany(f => f.MimeTypes).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.PutExtra(Intent.ExtraMimeTypes, mimeTypes); + } + + var pickerIntent = Intent.CreateChooser(intent, options?.Title ?? "Select file"); + + var tcs = new TaskCompletionSource(); + var currentRequestCode = _lastRequestCode++; + + _activity.ActivityResult += OnActivityResult; + _activity.StartActivityForResult(pickerIntent, currentRequestCode); + + var result = await tcs.Task; + + + if (result != null) + { + if (result.ClipData is { } clipData) + { + for (var i = 0; i < clipData.ItemCount; i++) + { + var uri = clipData.GetItemAt(i)?.Uri; + if (uri != null) + { + resultList.Add(new AndroidStorageFile(_activity, uri)); + } + } + } + else if (result.Data is { } uri) + { + resultList.Add(new AndroidStorageFile(_activity, uri)); + } + } + + void OnActivityResult(int requestCode, Result resultCode, Intent data) + { + if (currentRequestCode != requestCode) + { + return; + } + + _activity.ActivityResult -= OnActivityResult; + + _ = tcs.TrySetResult(resultCode == Result.Ok ? data : null); + } + } + catch (Exception ex) + { + Logger.TryGet(LogEventLevel.Error, LogArea.AndroidPlatform)?.Log(this, "Failed to open file picker. Error message: {Message}", ex.Message); + } + + return resultList; + } + + public async Task SaveFilePickerAsync(FilePickerSaveOptions options) + { + try + { + var mimeTypes = options.FileTypeChoices?.Where(t => t != FilePickerFileTypes.All) + .SelectMany(f => f.MimeTypes).Distinct().ToArray() ?? Array.Empty(); + + var intent = new Intent(Intent.ActionCreateDocument) + .AddCategory(Intent.CategoryOpenable) + .SetType(FilePickerFileTypes.All.MimeTypes![0]); + if (mimeTypes.Length > 0) + { + _ = intent.PutExtra(Intent.ExtraMimeTypes, mimeTypes); + } + + var pickerIntent = Intent.CreateChooser(intent, options?.Title ?? "Save file"); + + var tcs = new TaskCompletionSource(); + var currentRequestCode = _lastRequestCode++; + + _activity.ActivityResult += OnActivityResult; + _activity.StartActivityForResult(pickerIntent, currentRequestCode); + + var result = await tcs.Task; + if (result?.Data is { } uri) + { + return new AndroidStorageFile(_activity, uri); + } + + void OnActivityResult(int requestCode, Result resultCode, Intent data) + { + if (currentRequestCode != requestCode) + { + return; + } + + _activity.ActivityResult -= OnActivityResult; + + _ = tcs.TrySetResult(resultCode == Result.Ok ? data : null); + } + } + catch (Exception ex) + { + Logger.TryGet(LogEventLevel.Error, LogArea.AndroidPlatform)?.Log(this, "Failed to open save file picker. Error message: {Message}", ex.Message); + } + return null; + } + } +} diff --git a/src/Android/Avalonia.Android/SystemDialogImpl.cs b/src/Android/Avalonia.Android/SystemDialogImpl.cs deleted file mode 100644 index 1ed1f688b14..00000000000 --- 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(); - } - } -} diff --git a/src/Avalonia.Base/Logging/LogArea.cs b/src/Avalonia.Base/Logging/LogArea.cs index aa2b7bf8dd4..98ef6d25309 100644 --- a/src/Avalonia.Base/Logging/LogArea.cs +++ b/src/Avalonia.Base/Logging/LogArea.cs @@ -44,6 +44,11 @@ public static class LogArea /// The log event comes from X11Platform. /// public const string X11Platform = nameof(X11Platform); + + /// + /// The log event comes from AndroidPlatform. + /// + public const string AndroidPlatform = nameof(AndroidPlatform); /// /// The log event comes from IOSPlatform.